锁的探究
# 锁的探究
# 锁的是什么
锁是一种抽象的概念,那么在代码层面它究竟是如何实现的?
在Java中,主要采用了两种实现方式:
基于0bject的悲观锁
。基于CAS的乐观锁
。
所谓的悲观锁,也是是我们常常说的synchronized等之类的锁,
简单来说,在java中每个object,也就是每个对象都拥有一把锁。这把锁存放在对象头中,锁中记录了当前对象被哪个线程所占用。刚才提到了锁是存放在对象头中的,那么对象和对象头的结构分别是什么呢?
那乐观锁又是什么呢?
乐观锁(Optimistic Locking)是一种处理并发控制的方法。在更新数据时,并不会给对象加上锁,而是在提交更新时检查数据是否被其他事务修改过。如果检测到冲突,则拒绝此次操作,并允许用户根据实际情况进行重试或其他处理。
在锁的本质那部分会进行详细的讨论。
# 锁的一定是对象吗
先说结论:synchronized
锁的 一定是一个对象,具体分为以下三种情况:
- 锁当前对象实例
- 锁的是类的class对象
- 指定的对象
同步实例方法:锁的是当前对象实例(this
)
public class Counter {
private int count = 0;
public synchronized void increment() {
count++; // 锁的是当前实例(this)
}
}
2
3
4
5
6
7
- 当线程调用
counter1.increment()
时,锁的是counter1
对象。 - 不同实例(如
counter1
和counter2
)的同步方法互不干扰。
2. 同步静态方法:锁的是类的 Class 对象
public class Singleton {
private static Singleton instance;
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 锁的是 Singleton.class
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
- 当线程调用
Singleton.getInstance()
时,锁的是Singleton.class
对象(类的元数据对象)。 - 所有调用该静态方法的线程会竞争同一把锁。
同步代码块:锁的是显式指定的对象
public void doSomething() {
Object lock = new Object();
synchronized (lock) { // 锁的是 lock 对象
// 临界区代码
}
}
2
3
4
5
6
同步代码块可以灵活指定锁对象(可以是任意对象,包括 this
、Class
对象或自定义对象)。
锁对象的选择决定了同步的粒度:
- 如果多个线程竞争同一个锁对象,它们会互斥。
- 如果使用不同锁对象,线程之间不会互斥。
Java中的方法,传递的是“引用”,而不是“值”,那么此时会存在线程安全问题吗?
- 基本数据类型:如果方法参数是基本数据类型(如
int
,long
,boolean
等),因为它们是按值传递的,所以每个线程都会有这些参数的独立副本。因此,在这种情况下,不存在线程安全问题。- 不可变对象(Immutable Objects):如果方法参数是不可变对象(例如
String
,Integer
封装类等),由于其状态不能被改变,所以在多线程环境下也是线程安全的。即便多个线程同时访问同一个不可变对象,也不会导致状态不一致的问题。- 可变对象(Mutable Objects):当方法参数是可变对象(比如自定义类实例、集合类如
ArrayList
,HashMap
等),且这些对象被多个线程共享并可能在不同线程中被修改时,就可能存在线程安全问题。这是因为如果一个线程改变了该对象的状态,而另一个线程也在同一时间尝试读取或修改这个对象,可能会导致数据不一致或其他并发问题。
# 锁的本质
再谈锁的本质时,这里指的是悲观锁。
我们先来谈一下对象的结构。Java对象包含了三个部分,
- 对象头、
- 实例数据、
- 对齐填充字节。
其中对齐填充字节是为了满足java对象的大小必须是8比特的倍数这一条件而设计的。对齐填充字节正如它的名字一样,是为了帮助对象来对齐而填充的一些无用字节,大可不必理会。
.....
每个 Java 对象在内存中都有一个 对象头(Object Header)
,其中包含以下信息:
- Mark Word:存储对象的HashCode、锁状态标志、指向锁记录的指针、偏向线程ID、锁标志位等等。
- Class Pointer:指向类的元数据(Class 对象)。
当使用 synchronized
时,JVM 会通过对象头中的 Monitor(监视器锁) 实现锁机制:
- 线程进入
synchronized
块时,会尝试获取对象的 Monitor。 - 如果 Monitor 未被占用,线程成功获取锁;否则线程进入阻塞状态,直到锁被释放。
大家都知道在Java中,synchronized关键词可以用来同步线程,synchronized被编译后会生成monitorenter和monitorexit两个字节码指令,依赖这两个字节码指令来进行线程同步。
这就是synchronized关键字所实现的同步机制(主要流程),但是synchronized可能存在性能问题,因为monitor的下层是依赖于操作系统的MutexLock来实现的。
Java线程事实上是对操作系统线程的映射,所以每当挂起或唤醒一个线程都要切换到操作系统的内核态,这个操作是比较重量级的。在某些情况下,甚至切换时间本身就会超出线程执行任务的时间,这样的话,使用synchronized将会对程序的性能产生影响。
为了更优的性能,从Java6开始,synchronized进行了优化,引入了“偏向锁”、“轻量级锁”的概念。
因此对象锁总共有四种状态,从低到高分别是:“无锁”“偏向锁”“轻量级锁”“重量级锁”,这就分别对应了Mark Word中锁标记位的四种状态。
如果并发程度不高,可以只需要使用无锁,随着并发的程度越来越强烈,锁会慢慢的进行升级,直至变成“重量级锁”。需要注意的是,锁只能升级不能降级。
关于四种锁的区别这里只简要介绍:
Java虚拟机(JVM)提供了多种锁的优化技术以提高程序的性能和响应速度。以下是四种主要的锁状态:无锁、偏向锁、轻量级锁以及重量级锁。
# 1. 无锁(Lock-Free)
- 概念:无锁指的是不使用传统的互斥锁来保证线程安全的一种方式。它通常通过使用原子变量和比较并交换(Compare-And-Swap, CAS)等非阻塞算法实现。无锁编程允许多个线程同时访问共享资源而不必担心阻塞。
- 优点:高并发环境下效率更高,因为线程不会因等待锁而被阻塞。
- 缺点:编写正确且高效的无锁代码难度较大,并且可能会导致较高的CPU消耗,因为它可能涉及活跃度问题(如忙等待)。
# 2. 偏向锁(Biased Locking)
- 概念:偏向锁是一种针对同步块或方法调用几乎总是由同一个线程执行的情况进行优化的技术。它的目的是减少线程获取锁的代价。当一个线程访问同步块并获取到偏向锁后,会在对象头中标记该线程ID,之后同一线程再次进入时无需CAS操作直接进入,减少了开销。
- 优点:对于单线程环境或存在明显线程倾向性的场景下,可以显著降低锁获取成本。
- 缺点:如果存在多个线程竞争,则需要撤销偏向锁,这可能导致额外的性能损耗。
# 3. 轻量级锁(Lightweight Locking)
- 概念:轻量级锁适用于线程交替执行同步块的情况。它通过CAS操作尝试原子地将对象头指向当前线程的栈帧中的锁记录。如果成功,则表明没有其他线程竞争该锁;如果失败,则表示有其他线程持有该锁,此时会膨胀为重量级锁。
- 优点:相较于重量级锁,轻量级锁避免了操作系统层面的上下文切换,降低了开销。
- 缺点:随着竞争加剧,性能优势减弱,并最终升级为重量级锁。
# 4. 重量级锁(Heavyweight Locking)
- 概念:重量级锁即传统意义上的互斥锁(如
synchronized
关键字),它依赖于操作系统提供的互斥量(mutex)来实现同步。当一个线程获取重量级锁时,其他试图获取该锁的线程会被挂起,直到拥有锁的线程释放锁为止。 - 优点:适合于高度竞争的场景,能够确保只有一个线程可以访问临界区。
- 缺点:由于涉及到用户态到内核态的转换及线程挂起恢复的成本,开销较大,特别是在高并发情况下会导致严重的性能瓶颈。
# 锁的状态转换
锁的状态转换顺序通常是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。这种转换是为了适应不同的并发情况,从最优化但适用范围有限的方式逐步过渡到更通用但也更耗费资源的方式。
这四种锁的状态(无锁、偏向锁、轻量级锁、重量级锁)主要是由Java虚拟机(JVM)自动管理和转换的,而不是直接由开发者控制的。尽管开发者不能直接控制这些锁的状态转换,但可以通过一些方式间接影响它们的行为,比如JVM调优,进行JVM的参数设置。
# 乐观锁
假设现在有多个线程想要操作同一个资源对象,很多人的第一反应就是使用互斥锁(就是刚刚上文说的锁)。但互斥锁的同步方式是悲观的,什么是“悲观”呢?简单来说,就是操作系统将会悲观地认为,如果不严格同步线程调用,那么一定会产生异常,所以互斥锁将会锁定资源,只供一个线程调用,而阻塞其他线程,让其他线程等待,因此,这种同步机制也叫做“悲观锁”。
但悲观锁不是在所有情况下都适用,比如在一些情况下,同步代码块执行的耗时远远小于线程切换的耗时,这样就很不划算。程序员们可能更加希望一些场景下,能够在用户态中对线程的切换进行管理,这样效率更高。所以,我们不想让操作系统那么“悲观”,每次都使用同步原语对共享资源进行锁定,而是希望让线程反复“乐观”地去尝试获取共享资源,如果发现空闲,那么使用,如果被占用,那么继续“乐观”地重试。
那么具体该如何实现呢?想必一定有无数前人思考过这个问题,在他们的努力下,诞生了一种非常经典和巧妙的算法叫做CAS(Compare And Swap)。可以简单翻译为:比较然后交换。很多人都听说过CAS,但是对于它究竟是如何工作的?需要哪些外部支持?如何应用到业务中?可能并不是很了解,下面我就通过一个通俗的例子来进行介绍。
# 一个例子
我们现在假设有一间更衣室,房间门上挂着一块牌子,正面是0,反面是1,这块牌子代表房间是否被占用的状态。当显示0的时候,房间为空,谁都可以进入,当显示1时,则代表有人正在使用。在上面这个比喻里,房间就是共享资源,号码牌就是一把乐观锁,人就是线程。
假设此时A和B这两条线程都看到了牌子上显示的是0,于是争抢着去使用房间。但是A线程抢先获得了时间片,他第一个冲进房间并将这块牌子的状态改为1,此时B线程才冲过来,但是发现牌子上的状态已经被改为1,不过B线程没有放弃,不断回来看看牌子变回0了没。
以上就是乐观锁的核心思想:先比较,跟原来相同,则可以进行修改,否则不修改。
但上述——更衣室的例子存在一个明显的bug,如果两个人同时进入了房间,牌子怎么翻呢(谁去翻呢)。
其实,如果“比较”跟“修改”这两个操作是一个原子操作就可以实现——乐观锁了。“比较数值是否一致并且修改数值”的这个动作,必须要么成功要么失败,不能存在中间状态,换句话说,CAS操作必须是原子性的。只有基于这个真理,我们前面的所有设想才能成立。
那么,如何实现CAS的原子性呢?所幸的是,各种不同架构的CPU都提供了指令级的CAS原子操作,比如在x86架构下,通过cm pxchg指令支持CAS,在ARM下,通过LLSC来实现CAS。也就是说,既然CPU已经原生地支持了CAS,那么上层进行调用即可现在,除了通过操作系统的同步原语(比如互斥(Mutex)来有锁地实现线程同步悲观),通过CAS的方式我们能实现另一种无锁的同步机制(乐观)。
但在实际应用中,我们不会让B线程就这么放弃,通常会使其自旋,自旋就是使其不断重试cas操作,通常会配置自旋次数来防止死循环。
Java中的原子类——AtomicInteger,就是借用了cas的思想。
# ABA问题
使用了乐观锁的情况下,并不是说就不会有问题了,比如所谓的ABA问题。
ABA问题指的是一个变量在操作过程中被其他线程从A改成B,然后又改回A,这时候CAS操作会误以为没有变化,从而继续执行。这种情况可能会导致数据不一致的问题。
比如:假设有一个共享变量V
,其初始值为A
:
- 线程T1读取变量
V
的值,得到A
。 - 在T1尝试通过CAS将
V
从A
更新为C
之前,另一个线程T2介入:- T2先将
V
从A
改为B
, - 然后又迅速将
V
从B
改回了A
。
- T2先将
- 当T1继续执行CAS操作时,它看到
V
仍然等于A
,于是认为没有任何变化发生,成功地将V
设置为了C
。
尽管最终的结果看似正确,但实际过程中发生了两次不希望的变动,这可能会影响依赖于变量状态连续性的程序逻辑。
为了解决ABA问题,可以采用以下几种方法:
- 版本号/时间戳:给每个变量附加一个递增的版本号或时间戳,这样即使值回到了原来的值,版本号或时间戳也会不同,从而使得CAS能够识别出这种变化。
- AtomicStampedReference:Java提供了一个
AtomicStampedReference
类,它可以结合引用和整数“戳”一起使用,这里的戳通常是一个版本号或者计数器。通过这种方式,每次修改都会同时更新引用和戳,避免了单纯的值比较带来的问题。 - AtomicMarkableReference:如果只需要标记是否发生了变化而不是具体的次数,可以使用
AtomicMarkableReference
,它允许附加一个布尔标记来指示对象是否被修改过。