文章为在B站学习黑马并发编程视频整理笔记和个人理解,如果有错误,望指正
synchronized原理分析
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
阻塞式的解决方案:synchronized,Lock
非阻塞式的解决方案:原子变量
认识到synchronized可以通过是线程进入阻塞的方式来避免临界区竞态条件的发生
流程分析
创建两个线程来分别进行50万次进行++和–操作,使用synchronized通过对象锁控制线程进入阻塞,来保证代码块中指令的原子性;
@Slf4j
public class TestNoSyn {
public static int i = 0;
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
Thread thread1 = new Thread(() -> {
for (int j = 0; j < 500000; j++) {
synchronized (o) {
add();
}
}
}, "线程1");
Thread thread2 = new Thread(() -> {
for (int j = 0; j < 500000; j++) {
synchronized (o) {
dec();
}
}
}, "线程2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("结果打印为:"+i);
}
public static void add() {
i++;
}
public static void dec() {
i--;
}
}
加入synchronized锁之后,结果如我们所想变为0,上述流程分析如下,会多产生一次上下文切换
认识对象头
认识synchronized之前,我们得先了解对象头;在深入理解java虚拟机的学习课程中,之前有描述过对象的结构分为:对象头,对象实例数据部分,和对其填充部分;对象头又分为Markword和类型指针,类型指针指向jvm元空间中该对象的类元信息;之前自己画过图,大致如下图所示:
以64位虚拟机为例的话,Mark Word处于不同锁状态下的大致信息如下截图:
对象头信息在不同的锁状态下大致为:
Normal状态:正常状态,关键信息有 hash值,GC年领,是否是偏向锁标志(0表示否),最后三位为001表示正常无锁状态;
Biased状态:偏向锁状态,关键信息有偏向的线程id,GC年领,是否是偏向锁标志(0表示是),最后三位为101表示处于偏向锁状态;
Lightweight Locked状态:轻量级锁状态,关键信息有,栈帧中LockRecord对象的地址,最后两位为00表示处于轻量级锁状态;
Heavyweight Locked状态:重量级锁状态,关键信息有,Monitor对象的内存地址,最后两位为10表示处于重量级锁状态;
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
理解为,使用到轻量级锁时尚没有发生锁之间的竞争,也就是在一个线程获取到锁还未释放锁时,没有另一个线程此期间去获取到锁;
分析如下:在一个线程获取到锁时,此时栈中会创建一个锁记录对象Lock Record,该对象中记录lock锁对象的对象内存地址,还有当前lockRecord的内存地址;然后通过CSA操作尝试将lock锁的对象头与lockRecord的 lock record地址进行交换;即为如下演示:
如果在当前方法发生锁重入,则会再创建一个Lock Record,但此时尝试CAS交换对象头信息,发现已经有线程占有该lock,并且发现是自己重入,则CAS会失败,但是可以通过Lock Record的个数可以判断锁重入的次数;
代码验证:
@Slf4j
public class TestThinLock {
public static void main(String[] args) {
Object lock = new Object();
log.info("初始lock对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("进入synchronized后,lock对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("synchronized锁重入后,lock对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
}
}
}
}
打印结果如下:
分析:对照上文中的对象头表格,初始状态时,对象头最后三位为001表示正常无偏向锁状态,第一次获取到锁后对象头最后两位为00表示为轻量级锁状态;锁重入后,对象头信息并无发生改变;结果印证了上述理论;
锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁**(有竞争)**,这时需要进行锁膨胀,将轻量级锁变为重量级锁。
如下是演示图:
当thread2尝试进行交换对象头信息时,发现已经有线程占用,并且该线程不是自己,那么此时cas失败,并且进行锁膨胀,为lock申请一块monitor锁对象,让lock的对象头指向monitor对象,并且将monitor中的owner指向当前持有锁的lockrecord,并且thread2将自己加入到entryList中进行阻塞,等待thread1释放锁后,唤醒entryList中阻塞的线程来竞争锁;
代码验证:
@Slf4j
public class TestSynLock {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
log.info("初始含有偏向锁的对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
new Thread(() -> {
synchronized (lock) {
log.info("thread1获取锁之后,对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
try {
//睡眠三秒保证让thread1未执行完,thread2来竞争锁
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"thread1").start();
new Thread(() -> {
try {
//睡眠一秒保证thread1先获取锁
Thread.sleep(1000);
synchronized (lock) {
log.info("thread2竞争锁之后,对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
}
} catch (InterruptedException e) {
e.printStackTrace();
}
},"thread2").start();
}
}
分析:对照上文中的对象头表格,初始状态时,对象头最后三位为001表示正常无偏向锁状态,第一次thread1先获取到锁此时还为轻量级锁状态,对象头最后两位为00表示为轻量级锁状态;当thread1还未释放锁,此时thread2开始竞争锁,此时开始发生锁膨胀升级为重量级锁,对象头最后两位为10表示为重量级锁状态;结果印证了上述理论;
自旋锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。自旋锁的前提是当前已经是处于重量级锁状态,此时如果发生其他线程来进行锁竞争,那么先进行自旋来尝试获取锁,如果自旋后获取到锁,那么可以避免阻塞,如果未获取到锁,那么仍然会加入到entryList进入阻塞;
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有;
即是在竞争的场景比较少的情况下,使用偏向锁,避免锁重入导致cas操作过多影响程序性能,从二引入偏向锁通过线程id直接表示该线程对自己进行加锁并且锁重入时,只有第一次才进行cas将线程id设置进对象头信息,之后发现是该线程则不会再进行cas操作,以后只要不发生竞争,这个对象就归该线程所有。
偏向锁虽然默认是开启的,但是因为jvm启动时涉及到并发操作,所以偏向锁默认有延迟的,大概为4秒中,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
代码演示:
@Slf4j
public class TestBiasedLocking {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object lock = new Object();
log.info("初始偏向锁lock对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
log.info("进入到synchronized后,lock对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
}
log.info("锁释放后,lock对象头信息={}", ClassLayout.parseInstance(lock).toPrintable());
}
}
从结果可以看出,启用了偏向锁之后,在未有锁竞争时,则会将第一个获取到锁的线程设置到对象头,释放锁之后,依然保持不变;
偏向锁的失效场景:调用hashcode方法,调用wait()和notify()锁升级为重量级锁,发生锁竞争升级为轻量级锁;
关于偏向锁发生重偏向和撤销,则可自行了解;
wait()和notify原理
wait方法原理: 在synchronized中,我们调用wait()方法进入WAITING等待状态,并且释放锁,在monitor中,我们等同于从owner进入到了waitSet WAITING线程集合中,WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争,例如下图中的thread1和thread2;
调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
BLOCKED 线程会在 Owner 线程释放锁时唤醒