本文是JUC第四讲:Java中的锁主要用于保障多并发线程情况下数据的一致性。在多线程编程中为了保障数据的一致性,我们通常需要在使用对象或者方法之前加锁,这时如果有其他线程也需要使用该对象或者该方法,则首先要获得锁,如果某个线程发现锁正在被其他线程使用,就会进入阻塞队列等待锁的释放,直到其他线程执行完成并释放锁,该线程才有机会再次获取锁进行操作。这样就保障了在同一时刻只有一个线程持有该对象的锁并修改对象,从而保障数据的安全
锁从乐观和悲观的角度可分为乐观锁和悲观锁,
乐观锁
悲观锁
从获取资源的公平性角度可分为公平锁和非公平锁
从是否共享资源的角度可分为共享锁和独占锁
从锁的状态的角度可分为偏向锁、轻量级锁和重量级锁。同时,在JVM中还巧妙设计了自旋锁以更快地使用CPU资源。下面将详细介绍这些锁
重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t(互斥锁)来实现。
这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。
而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。
偏向锁的好处是在只有一个线程获取锁的情况下,只需要通过一次 CAS 操作修改 markword ,之后每次进行简单的判断即可,避免了轻量级锁每次获取释放锁时的 CAS 操作。
如果确定同步代码块会被多个线程访问或者竞争较大,可以通过 -XX:-UseBiasedLocking
参数关闭偏向锁。
1)偏向锁
2)轻量级锁
3)重量级锁
自旋发生在重量级锁阶段。
网上99.99%的说法,自旋都是发生在轻量级锁阶段,但是实际看了源码(JDK8)之后,并不是这样的。
轻量级锁阶段并没有自旋操作,在轻量级锁阶段,只要发生竞争,就是直接膨胀成重量级锁。
而在重量级锁阶段,如果获取锁失败,则会尝试自旋去获取锁。
因为重量级锁的挂起开销太大。
自适应自旋锁有自旋次数限制,范围在:1000~5000。
Java提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。
当且仅当满足以下所有条件时,才应该使用volatile变量
使用建议:
缺点:
public class SynchronizedTest {
// 保证内存可见性
public static volatile int race = 0;
private static CountDownLatch countDownLatch = new CountDownLatch(2);
public static void main(String[] args) throws InterruptedException {
// 循环开启2个线程来计数
for (int i = 0; i < 2; i++) {
new Thread(() -> {
// 每个线程累加1万次
for (int j = 0; j < 10000; j++) {
race++;
}
countDownLatch.countDown();
}).start();
}
// 等待,直到所有线程处理结束才放行
countDownLatch.await();
// 期望输出 2万(2*1万)
System.out.println(race);
}
}
熟悉的2个线程计数的例子,每个线程自增1万次,预期的结果是2万,但是实际运行结果总是一个小于等于2万的数字,为什么会这样了?
为了得到正确的结果,此时我们可以将 race++ 使用 synchronized 来修饰,如下:
synchronized (SynchronizedTest.class) {
race++;
}
public synchronized void method() {}
public static synchronized void method() {}
synchronized (Lock.class) {}
synchronized (this) {}
public static Object monitor = new Object();
synchronized (monitor) {}
public class SynchronizedTest {
private static final Object lock = new Object();
public static void testWait() throws InterruptedException {
lock.wait();
}
public static void testNotify() throws InterruptedException {
lock.notify();
}
}
public class SynchronizedTest {
private static final Object lock = new Object();
public static synchronized void getLock() throws InterruptedException {
lock.wait();
}
}
这题是紧接着上一题的,很明显面试官想看看我是不是真的对 synchronize 底层原理有所了解。
synchronized 底层对应的 JVM 模型为 objectMonitor,使用了3个双向链表来存放被阻塞的线程:_cxq(Contention queue)、_EntryList(EntryList)、_WaitSet(WaitSet)。
当线程获取锁失败进入阻塞后,首先会被加入到 _cxq 链表,_cxq 链表的节点会在某个时刻被进一步转移到 _EntryList 链表。
具体转移的时刻?见题目30。
因为被唤醒的线程并不是就一定获取到锁了,该线程仍然需要去竞争锁,而且可能会失败,所以该线程并不是就一定会成为锁的“继承者”,而只是有机会成为,所以我们称它为假定的。
答案是可以的。
具体的触发时机:在全局安全点(safepoint)中,执行清理任务的时候会触发尝试降级锁。
当锁降级时,主要进行了以下操作:
Synchronized 的非公平其实在源码中应该有不少地方,因为设计者就没按公平锁来设计,核心有以下几个点:
1)当持有锁的线程释放锁时,该线程会执行以下两个重要操作:
2)当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。
public class SynchronizedTest {
private static final Object lock = new Object();
public static void testWait() throws InterruptedException {
synchronized (lock) {
// 阻塞住,被唤醒之前不会输出aa,也就是还没离开synchronized
lock.wait();
System.out.println("aa");
}
}
public static void testNotify() throws InterruptedException {
synchronized (lock) {
lock.notify();
System.out.println("bb");
}
}
}
只看代码确实会给人题目中的这种错觉,这也是 Object 的 wait() 和 notify() 方法很多人用不好的原因,包括我也是用的不太好。
这个题需要从底层去看,当线程进入 synchronized 时,需要获取 lock 锁,但是在调用 lock.wait() 的时候,此时虽然线程还在 synchronized 块里,但是其实已经释放掉了 lock 锁。
所以,其他线程此时可以获取 lock 锁进入到 synchronized 块,从而去执行 lock.notify()。
答案是否定的。上面在介绍 synchronized 为什么是非公平锁时也介绍了不会按照顺序去唤醒。
所以,唤醒的顺序并不一定是进入 wait 时的顺序。
特点:
ReentrantLock 的内部类Sync继承了AQS,分为公平锁FairSync和非公平锁NonfairSync
优点:
核心流程如下图所示
synchronized 的底层实现主要区分:方法和代码块,如下图例子。
public class SynchronizedDemo {
private static final Object lock = new Object();
public static void main(String[] args) {
// 锁作用于代码块
synchronized (lock) {
System.out.println("hello word");
}
}
// 锁作用于方法
public synchronized void test() {
System.out.println("test");
}
}
将该代码进行编译后,查看其字节码,核心代码如下:
{
public com.joonwhee.SynchronizedDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 9: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // Field lock:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter // 进入同步块
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc #4 // String hello word
11: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
14: aload_1
15: monitorexit // 退出同步块
16: goto 24
19: astore_2
20: aload_1
21: monitorexit // 退出同步块
22: aload_2
23: athrow
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
public synchronized void test();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标记
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String test
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 20: 0
line 21: 8
}
synchronized 修饰代码块时,编译后会生成 monitorenter 和 monitorexit 指令,分别对应进入同步块和退出同步块。可以看到有两个 monitorexit,这是因为编译时 JVM 为代码块添加了隐式的 try-finally,在 finally 中进行了锁释放,这也是为什么 synchronized 不需要手动释放锁的原因。
synchronized 修饰方法时,编译后会生成 ACC_SYNCHRONIZED 标记,当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了则会先尝试获得锁。
两种实现其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
Synchronized的语义底层是通过一个monitor(监视器锁)对象来完成的,当monitor被占用时处于锁定状态
线程执行monitorenter指令时尝试获取monitor的所有权:
线程执行monitorexit指令完成锁的释放
优化:
锁的升级降级:
在介绍 Mark Word 之前,需要先了解对象的内存布局。 HotSpot 中,对象在堆内存中的存储布局可以分为三部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
1)对象头(Header)
2)实例数据(Instance Data)
3)对齐填充(Padding)
锁记录,这个大家应该都听过,用于轻量级锁时暂存对象的 markword。
Lock Record 在源码中为 BasicObjectLock,源码如下:
class BasicObjectLock VALUE_OBJ_CLASS_SPEC {
private:
BasicLock _lock;
oop _obj;
};
class BasicLock VALUE_OBJ_CLASS_SPEC {
private:
volatile markOop _displaced_header;
};
其实就两个属性:
1)_displaced_header:用于轻量级锁中暂存锁对象的 markword,也称为 displaced mark word。
2)_obj:指向锁对象。
Lock Record 除了用于暂存 markword 之外,还有一个重要的功能是用于实现锁重入的计数器,当每次锁重入时,会用一个 Lock Record 来记录,但是此时 _displaced_header 为 null。
这样在解锁的时候,每解锁1次,就移除1个 Lock Record。移除时,判断 _displaced_header 是否为 null。如果是,则代表是锁重入,则不会执行真正的解锁;否则,代表这是最后一个 Lock Record,此时会真正执行解锁操作。
所谓的匿名偏向是指该锁从未被获取过,也就是第一次偏向,此时的特点是锁对象 markword 的线程 ID 为0。
当第一个线程获取偏向锁后,线程ID会从0修改为该线程的 ID,之后该线程 ID 就不会为0了,因为释放偏向锁不会修改线程 ID。
这也是为什么说偏向锁适用于:只有一个线程获取锁的场景。
偏向锁状态下是没有地方存放 hashCode 的。
因此,当一个对象已经计算过 hashCode 之后,就再也无法进入偏向锁状态了。
如果一个对象当前正处于偏向锁状态,收到需要计算其 hashCode 的请求时(Object::hashCode()或者System::identityHashCode(Object)方法的调用),它的偏向锁状态就会立即被撤销。
首先,在开启偏向锁的时候,对象创建后,其偏向锁标记位为1。如果没开启偏向锁,对象创建后,其偏向锁标记位为0。
加锁流程:
1)从当前线程的栈帧中寻找一个空闲的 Lock Record,将 obj 属性指向当前锁对象。
2)获取偏向锁时,会先进行各种判断,如加锁流程图所示,最终只有两种场景能尝试获取锁:匿名偏向、批量重偏向。
3)使用 CAS 尝试将自己的线程 ID 填充到锁对象 markword 里,修改成功则获取到锁。
4)如果不是步骤2的两种场景,或者 CAS 修改失败,则会撤销偏向锁,并升级为轻量级锁。
5)如果线程成功获取偏向锁,之后每次进入该同步块时,只需要简单的判断锁对象 markword 里的线程ID是否自己,如果是则直接进入,几乎没有额外开销。
解锁流程:
上面我们提到了批量重偏向,与批量重偏向同时被引入的还有批量撤销,官方统称两者为 “启发式算法”。
为什么引入启发式算法?
从上面的介绍我们知道,当只有一个线程获取锁时,偏向锁只需在第一次进入同步块时执行一次 CAS 操作,之后每次进入只需要简单的判断即可,此时的开销基本可以忽略。因此在只有一个线程获取锁的场景中,偏向锁的性能提升是非常可观的。
但是如果有其他线程尝试获得锁时,此时需要将偏向锁撤销为无锁状态或者升级为轻量级锁。偏向锁的撤销是有一定成本的,如果我们的使用场景存在多线程竞争导致大量偏向锁撤销,那偏向锁反而会导致性能下降。
JVM 开发人员通过分析得出以下两个观点:
观点1:对于某些对象,偏向锁显然是无益的。例如涉及两个或更多线程的生产者-消费者队列。这样的对象必然有锁竞争,而且在程序执行过程中可能会分配许多这样的对象。
该观点描述的是锁竞争比较多的场景,对这种场景,一种简单粗暴的方法是直接禁用偏向锁,但是这种方式并不是最优的。
因为在整个服务中,可能只有一小部分是这种场景,因为这一小部分场景而直接放弃偏向锁的优化,显然是不划算的。最理想的情况下是能够识别这样的对象,并只为它们禁用偏向锁。
批量撤销就是对该场景的优化。
观点2:在某些情况下,将一组对象重新偏向另一个线程是有好处的。特别是当一个线程分配了许多对象并对每个对象执行了初始同步操作,但另一个线程对它们执行了后续工作。
我们知道偏向锁的设计初衷是用于只有一个线程获取锁的场景。该观点中后半部分其实是符合这场场景的,但是由于前半部分而导致不能享受偏向锁带来的好处,因此 JVM 开发人员要做的就是识别出这场场景,并进行优化。
对于这种场景,官方引入了批量重偏向来进行优化。
批量重偏向
JVM 选择以 class 为粒度,为每一个 class 维护了一个偏向锁撤销计数器。每当该 class 的对象发生偏向锁撤销的时候,计数器值+1。
当计数器的值超过批量重偏向的阈值(默认20)的时候,JVM 认为此时命中了上述的场景2,就会对整个 class 进行批量重偏向。
每个 class 都会有 markword,当处于偏向锁状态时,markword 会有 epoch 属性,当创建该 class 的实例对象时,实例对象的 epoch 值会赋值为 class 的 epoch 值,也就是说正常情况下,实例对象的 epoch 和 class 的 epoch 是相等的。
而当发生批量重偏向时,epoch 就派上用场了。
当发生批量重偏向时,首先会将 class 的 epoch 值+1,接着遍历所有当前存活的线程的栈,找到该 class 所有正处于偏向锁状态的锁实例对象,将其 epoch 值修改为新值。
而那些当前没有被任何线程持有的锁实例对象,其 epoch 值则没有得到更新,此时会比 class 的 epoch 值小1。在下一次其他线程准备获取该锁对象的时候,不会因为该锁对象的线程ID不为0(也就是曾经被其他线程获取过),而直接升级为轻量级锁,而是使用 CAS 来尝试获取偏向锁,从而达到批量重偏向的优化效果。
PS:对应了加锁流程图中的 “锁对象的epoch等于class的epoch?” 的选择框。
批量撤销
批量撤销是批量重偏向的后续流程,同样是以 class 为粒度,同样使用偏向撤销计数器。
当批量重偏向后,每次进行偏向撤销时,会计算本次撤销时间和上一次撤销时间的间隔,如果两次撤销时间的间隔超过指定时间(25秒),则此时 JVM 会认为批量重偏向是有效果的,因为此时偏向撤销的频率很低,所以会将偏向撤销计数器重置为0。
而当批量重偏向后,偏向计数器的值继续快速增加,当计数器的值超过批量撤销的阈值(默认40)时,JVM 认为该 class 的实例对象存在明显的锁竞争,不适合使用偏向锁,则会触发批量撤销操作。
批量撤销:
将 class 的 markword 修改为不可偏向无锁状态,也就是偏向标记位为0,锁标记位为01。接着遍历所有当前存活的线程的栈,找到该 class 所有正处于偏向锁状态的锁实例对象,执行偏向锁的撤销操作。
这样当线程后续尝试获取该 class 的锁实例对象时,会发现锁对象的 class 的 markword 不是偏向锁状态,知道该 class 已经被禁用偏向锁,从而直接进入轻量级锁流程。
PS:对应了加锁流程图中的 “锁对象的class是否为偏向模式?” 的选择框。
加锁流程:
如果关闭偏向锁,或者偏向锁升级,则会进入轻量级锁加锁流程。
1)从当前线程的栈帧中寻找一个空闲的 Lock Record,obj 属性指向锁对象。
2)将锁对象的 markword 修改为无锁状态,填充到 Lock Rrcord 的 displaced_header 属性。
3)使用 CAS 将对象头的 markword 修改为指向 Lock Record 的指针
解锁流程:
加锁流程:
当轻量级锁出现竞争时,会膨胀成重量级锁。
1)分配一个 ObjectMonitor,并填充相关属性。
2)将锁对象的 markword 修改为:该 ObjctMonitor 地址 + 重量级锁标记位(10)
3)尝试获取锁,如果失败了则尝试自旋获取锁
4)如果多次尝试后还是失败,则将该线程封装成 ObjectWaiter,插入到 cxq 链表中,当前线程进入阻塞状态
5)当其他锁释放时,会唤醒链表中的节点,被唤醒的节点会再次尝试获取锁,获取成功后,将自己从 cxq(EntryList)链表中移除
ObjectMonitor() {
_header = NULL; // 锁对象的原始对象头
_count = 0; // 抢占该锁的线程数,_count大约等于 _WaitSet线程数 + _EntryList线程数
_waiters = 0, // 调用wait方法后的等待线程数
_recursions = 0; // 锁的重入数
_object = NULL; // 指向锁对象指针
_owner = NULL; // 当前持有锁的线程
_WaitSet = NULL; // 存放调用wait()方法的线程
_WaitSetLock = 0 ; // 操作_WaitSet链表的锁
_Responsible = NULL ;
_succ = NULL ; // 假定继承人
_cxq = NULL ; // 等待获取锁的线程链表,竞争锁失败后会被先放到cxq链表,之后再进入_EntryList链接
FreeNext = NULL ; // 指向下一个空闲的ObjectMonitor
_EntryList = NULL ; // 等待获取锁的线程链表,该链表的头结点是获取锁的第一候选者
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ; // 标记_owner是指向占用当前锁的线程的指针还是BasicLock,1为线程,0为BasicLock,发生在轻锁升级重锁的时候
_previous_owner_tid = 0; // 监视器上一个所有者的线程id
}
解锁流程:
上文说道,“_cxq 链表的节点会在某个时刻被进一步转移到 _EntryList 链表”,那到底是什么时刻了?
通常来说,可以认为是在持有锁的线程释放锁时,该线程需要去唤醒链表中的下一个线程节点,此时如果检查到 _EntryList 为空,并且 _cxq 不为空时,会将 _cxq 链表的节点转移到 _EntryList 中。
不过也不全是这样,_cxq 链表和 _EntryList 链表的排队策略的排队策略(QMode)和执行顺序如下:
CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。CAS也是现在面试经常问的问题,本文将深入的介绍CAS的原理
介绍CAS之前,我们先来看一个例子。
import java.util.concurrent.CountDownLatch;
public class VolatileTest {
// volatile 保障内存可见性
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;
private static CountDownLatch countDownLatch = new CountDownLatch(THREADS_COUNT);
// 非原子性
public static void increase() {
race++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
countDownLatch.countDown();
}
});
threads[i].start();
}
countDownLatch.await();
System.out.println(race);
}
}
解决方法:
public static synchronized void increase() {
// 非原子操作,取值,加一,写值
race++
}
public static AtomicInteger race = new AtomicInteger(0);
public static synchronized void increase() {
// 原子操作
race.getAndIncrement();
}
通过方法调用,我们可以发现,getAndIncrement方法调用getAndAddInt方法,最后调用的是compareAndSwapInt方法,即本文的主角CAS,接下来我们开始介绍CAS。
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 拿到内存位置的最新值
v = this.getIntVolatile(o, offset);
} while(!this.compareAndSwapInt(o, offset, v, v + delta)); // CAS修改成功才跳出循环
return var5;
}
compareAndSwap(JNI, Java Native Interface)
方法实现的,
CAS 的使用流程通常如下:
但是在第1步中读取的值是A,并且在第3步修改成功了,我们就能说它的值在第1步和第3步之间没有被其他线程改变过了吗?
CAS操作的“ABA”问题
如何解决ABA问题
AtomicStampedReference
,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效public class AtomicBTreePartition {
private volatile long lock;
public void acquireLock(){}
public void releaseeLock(){}
}
java.util.concurrent.atomic.AtomicLongFieldUpdater
,它是基于反射机制创建,我们需要保证类型和字段名称正确private static fnal AtomicLongFieldUpdater<AtomicBTreePartition> lockFieldUpdater = AtomicLongFieldUpdater.newUpdater(AtomicBTreePartition.class, "lock");
private void acquireLock(){
long t = Thread.currentThread().getId();
while (!lockFieldUpdater.compareAndSet(this, 0L, t)){
// 等待一会儿,数据库操作可能比较慢
…
}
}
非公平锁(可以控制公平性)
注意事项:
1、在finally中释放锁,目的是保证在获取锁之后,最终能够被释放;
2、不要将获取锁的过程卸载try块中,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故被释放
3、reentrantLock提供了一个newCondition方法,以便用户在同一把锁的情况下,可以根据不同的情况执行等待或唤醒动作