深入学习synchronized
一. 并发编程中的问题
1. 可见性问题
可见性问题是指一个线程对共享变量修改后,另一个线程可以立即得到修改后的最新值
/**
案例演示:
一个线程对共享变量的修改,另一个线程不能立即得到最新值
*/
public class Test01Visibility {
// 多个线程都会访问的数据,我们称为线程的共享数据
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
run = false;
System.out.println("时间到,线程2设置为false");
});
t2.start();
}
}
并发编程过程中,会出现可见性问题,即一个线程对共享变量进行修改后,另一个线程并没有立即看见修改后的最新值
2. 原子性问题
原子性是指在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰中断,要么所有操作都不执行
/**
案例演示:5个线程各执行1000次 i++;
*/
public class Test02Atomicity {
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
number++;
}
};
ArrayList<Thread> ts = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
ts.add(t);
}
for (Thread t : ts) {
t.join();
}
System.out.println("number = " + number);
}
}
对于number++而言,其对于的JVM字节码指令有四条:
9: getstatic #12 // Field number:I
12: iconst_1
13: iadd
14: putstatic #12 // Field number:I
所以由number++不是原子性的操作,在一个线程下操作不会出问题,但是在多线程操作下会出问题,具体表现是多次number++的实际效果只有一次
并发编程过程中,会出现原子性问题,一个线程对变量操作到一半时,另一个线程也有可能来操作共享的变量,干扰前一个线程的操作
3. 有序性问题
有序性是指Java代码的执行顺序,Java在编译和运行过程中会对代码进行优化,最终代码的执行顺序不一定时代码的编写顺序
二. Java内存模型
Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节,具体如下
- 主内存
主内存是所有的线程都共享的,都可以访问,所有的共享变量都存储在主内存 - 工作内存
每个线程都有自己的工作内存,工作内存中只存储该线程对共享变量的副本。线程对变量的所有操作都在工作内存中完成,而不能直接读写主内存的变量,不同线程的工作内存是隔离的
Java内存模型的作用:
Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性和原子性的规则和保障
主内存与工作内存间数据交互:
- 如果对一个变量执行lock操作,会清空工作内存中此变量的值
- 对一个变量执行unlock之前,必须把该变量同步到主内存
三. synchronized保证三大特性
1. 保证原子性
synchronized能够保证同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果
2. 保证可见性
synchronized保证可见性的原理是执行synchronized代码块里的代码是,对应的lock原子操作会刷新工作内存中共享变量的值,释放锁时,会把变量同步到主内存
3. 保证有序性
JVM重排序的作用:
提高代码的执行效率,编译器和CPU会对程序中代码进行重启排序
as-if-serial语义:
意思是不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果时正确的
比如数据有依赖关系,则不可进行重排序:
// 写后读
int a = 1;
int b = a;
// 写后写
int a = 1;
int a = 2;
// 读后写
int a = 1;
int b = a;
int a = 2;
编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序
synchronized保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。保证有序性
四. synchronized的特性
1. 可重入性
可重入即一个线程可以多次执行synchronized重复获取同一把锁
synchronized是可重入锁,内部锁对象中会有一个计数器记录同一个线程获取了几次锁,在执行完同步代码块后,计算器的数量会-1,直到计数器的数量为0时,释放锁
可重入的好处:
- 避免发生死锁
- 可以更好的封装代码(在synchronized方法中调用另一个synchronized方法)
2. 不可中断性
不可中断性是指处于阻塞状态的线程不可中断
一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断
- synchronized属于不可被中断
- Lock的lock方法是不可中断的
- Lock的tryLock方法是可中断的
Thread.interrupt()
- Java中一个用于中断线程的方法。当一个线程调用interrupt()方法时,它会将该线程的中断标志位设置为true,表示该线程已经被中断。
- 如果线程正在等待某个阻塞状态(如等待输入/输出、等待获取锁、等待sleep()结束等),那么它会立即抛出InterruptedException异常,从而使线程退出阻塞状态,继续执行后续代码。如果线程没有处于阻塞状态,那么该标记位会在线程下一次进入阻塞状态时被检查。
- 需要注意的是,Thread.interrupt()并不会真正中断线程的执行。它只是向线程发出中断请求,线程可以根据自己的实现来响应这个请求。一些常见的响应方式包括:
- 在捕获InterruptedException异常后,清除中断标志位,并进行相应的处理。
- 在循环中使用Thread.interrupted()方法来检查中断标志位,并根据标志位的状态来决定是否继续执行循环。
- 在执行长时间任务时定期检查中断标志位,并根据标志位的状态来决定是否继续执行任务
五. synchronized原理
synchronized关键字反编译后是monitorenenter与monitorexit两条字节码指令
1. monitorenter
每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。 当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下:
- 若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner(所有者)
- 若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
- 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权
总结: synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待
2. monitorexit
monitorexit的描述如下:
- 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
- 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权
Q:面试题synchroznied出现异常会释放锁吗?
会。monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit
3. monitor监视器锁
在HotSpot虚拟机中,monitor是由ObjectMonitor实现的。其源码是用c++来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中(src/share/vm/runtime/objectMonitor.hpp)。ObjectMonitor主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL; // 存储该monitor的对象
_owner = NULL; // 标识拥有该monitor的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL; // 多线程竞争锁时的单向列表
FreeNext = NULL;
_EntryList = NULL; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
}
- _owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程
释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线
程安全的。 - _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临界资
源,JVM通过CAS原子指令来修改_cxq队列。修改前_cxq的旧值填入了node的next字段,_cxq指
向新值(新线程)。因此_cxq是一个后进先出的stack(栈)。 - _EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中。
- _WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中。
每一个Java对象都可以与一个监视器monitor关联,我们可以把它理解成为一把锁,当一个线程想要执
行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象
对应的monitor。
我们的Java代码里不会显示地去创造这么一个monitor对象,我们也无需创建,事实上可以这么理解:
monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉JVM需要为我们的某个对
象创建关联的monitor对象。每个线程都存在两个ObjectMonitor对象列表,分别为free和used列表。
同时JVM中也维护着global locklist。当线程需要ObjectMonitor对象时,首先从线程自身的free表中申
请,若存在则使用,若不存在则从global list中申请
4. monitor竞争
monitor竞争具体流程概括如下:
- 通过CAS尝试把monitor的owner字段设置为当前线程。
- 如果设置之前的owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行
recursions ++ ,记录重入的次数。 - 如果当前线程是第一次进入该monitor,设置recursions为1,_owner为当前线程,该线程成功获
得锁并返回。 - 如果获取锁失败,则等待锁的释放。
5. monitor等待
monitor等待具体流程概括如下:
- 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ。
- 在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node
节点push到_cxq列表中。 - node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当
前线程挂起,等待被唤醒。 - 当该线程被唤醒时,会从挂起的点继续执行,通过 ObjectMonitor::TryLock 尝试获取锁。
6. monitor释放
某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其它线程机会执行同步代码,在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程,具体实现位于ObjectMonitor的exit方法中。monitor释放具体流程概括如下:
- 退出同步代码块时会让_recursions减1,当_recursions的值减为0时,说明线程释放了锁。
- 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog 方法唤醒该节点封装的线程,唤醒操作最终由unpark完成,被唤醒的线程,会回到 void ATTR ObjectMonitor::EnterI (TRAPS) 的第600行,继续执行monitor的竞争
六. synchronized优化
1. CAS
CAS概述与作用:
CAS的全成是: Compare And Swap(比较相同再交换)。是现代CPU广泛支持的一种对内存中的共享数
据进行操作的一种特殊指令。
CAS的作用:CAS可以将比较和交换转换为原子操作,这个原子操作直接由CPU保证。CAS可以保证共
享变量赋值时的原子操作。
Java中的CAS(Compare and Swap)是一种无锁的线程同步机制,它允许多个线程同时访问共享资源,而不需要加锁。CAS操作包含三个参数:内存位置V、期望值A和新值B。它的操作过程如下:
- 检查内存位置V的值是否等于期望值A,如果是,则将内存位置V的值更新为新值B,并返回true;否则不做任何操作,返回false。
- 如果CAS操作返回false,则表示内存位置V的值已经被其他线程修改过,需要重新读取内存位置V的值,并重新进行CAS操作
2. 乐观锁与悲观锁
1、 悲观锁从悲观的角度出发:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这
样别人想拿这个数据就会阻塞。因此synchronized我们也将其称之为悲观锁。JDK中的ReentrantLock
也是一种悲观锁。性能较差!
2、乐观锁从乐观的角度出发:
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,就算改了也没关系,再重试即可。所
以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去修改这个数据,如何没有人修改则更
新,如果有人修改则重试
CAS这种机制我们也可以将其称之为乐观锁。综合性能较好
3. CAS与synchronized
CAS获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。结合CAS和volatile可以
实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
1、因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
2、但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响。
4. synchronized锁升级过程
高效并发是从JDK 5到JDK 6的一个重要改进,HotSpot虛拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,包括偏向锁( Biased Locking )、轻量级锁( Lightweight Locking )和如适应性自旋(Adaptive Spinning)、锁消除( Lock Elimination)、锁粗化( Lock Coarsening )等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
无锁–》偏向锁–》轻量级锁–》重量级锁
5. Java对象布局
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
1、Mark Word
用于存储对象自身的运行数据,比如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机长一致
64位虚拟机Mark Word存储结构如下:
2、Klass pointer
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位
3、数据填充
对齐填充并不是必然存在的,也没有什么特别的意义,他仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
6. 偏向锁
什么是偏向锁:
偏向锁是JDK 6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以ThreadID即可
不过一旦出现多个线程竞争时必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前节省下来的CAS原子操作的性能消耗,不然就得不偿失了
偏向锁原理:
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高
偏向锁优点:
偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能
7. 轻量级锁
什么是轻量级锁:
轻量级锁是JDK 6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的。
引入轻量级锁的目的:
在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁
轻量级锁原理:
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步
骤如下:
1、获取锁
- 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作。
- 如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。
2、释放锁
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在Displaced Mark Word中的数据。
- 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁
轻量级锁好处:
在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗
8. 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这项技术就是所谓的自旋锁
自适应自旋: 自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性 能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了
9. 锁消除
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象
10. 锁粗化
JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可