目录
1、什么是synchronize?
2、为什么要用synchronize锁?
2.1 代码演示
2.2 原因分析
2.3 专有名词解释
2.3.1 临界资源
2.3.2 临界区
2.3.3 竞态条件
3、synchronize锁的原理
3.1 锁升级过程
3.1.1 偏向锁
3.1.2 轻量级锁
3.1.3 重量级锁
3.1.4 总体过程
3.2 锁优化
3.2.1 自旋锁
3.2.2 锁粗化
3.2.3 锁消除
4. synchronize的基本使用
4.1 使用方式
4.1.1 synchronize锁某个对象
4.1.2 synchronize锁某个类
4.1.3 synchronize锁某个类的成员方法
4.1.4 synchronize锁住某个类的静态方法
4.1.5 wait/notify
1、什么是synchronize?
官方:同步方法支持一种简单的策略来防止线程受到干扰和内存一致性错误;如果一个对象对多个线程可见,则对该对象变量的所有读取或写入都是通过同步方法完成
通俗点来说就是程序中用于保护线程安全的一种机制。
2、为什么要用synchronize锁?
2.1 代码演示
public class Test19 {
public static int a=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程1对a进行50000次a++
a++;
}
}
});
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程2对a进行50000次a--
a--;
}
}
});
t1.start();
t2.start();
//等待t1 t2线程运行完后输出a的值
t1.join();
t2.join();
System.out.println(a);
}
}
按理来说,a最后输出的值应该是0,但是大家在运行上述代码后会发现结果并不是确定的 。可见,这里在多线程对共享变量操作的情况下发生了线程安全问题。
但是呢,如果我们给线程加上synchronize锁后,会发现最后的值永远是0,代码如下:
public class Test19 {
public static int a=0;
public static Object object=new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程1对a进行50000次a++
synchronized (object){ //加上synchronize锁
a++;
}
}
}
});
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程2对a进行50000次a--
synchronized (object){ //加上synchronize锁
a--;
}
}
}
});
t1.start();
t2.start();
//等待t1 t2线程运行完后输出a的值
t1.join();
t2.join();
System.out.println(a);
}
}
2.2 原因分析
首先大家先要知道,我们编程语言中对于a++或者a--这个操作来说,并不是原子性的,而是在编译后会被分别翻译成如下四条指令:
a++:
getstatic a // 获取静态变量a 的值a const_1 // 准备常量 1a add // 自增putstatic a // 将修改后的值存入静态变量a
a--:
getstatic a // 获取静态变量a的值
a const_1 // 准备常量 1a sub // 自减putstatic a // 将修改后的值存入静态变量a
可见,对于多线程情况下会出现线程安全问题的原因也很好理解:比如我第一个线程t1进行a++,第二个线程t2进行a--,此时t1取出了a的值0,同时t2也取出了a的值,由于此时t1只是取出a的值,并没有对a进行操作并且赋值到原来的地址上,因此t2取出a的值也为0,然后接下来t1准备常量1,然后让a+=常量,再赋值,此时a变为1。同理,t2也进行准备常量1,然后对a-=常量,由于t2取出的a是t1赋值之前的,因此t2再对a操作后,a的值变为了-1 !
这不就出现线程安全了吗!原本两个线程正常情况下对a操作后应该是a为0,但是此时a为-1!以上例子简单的解释了一下为什么多线程情况下对共享变量进行操作会有线程安全问题。
当然,上述例子只是某一种情况,也有可能是t2先操作完,t1再操作,因此最后值为1,大家可以自行去思考有多少种情况。
总结:线程安全问题是由于多线程在临界区内对临界资源同时访问发生了竞态条件导致的。
那么?什么是临界区?什么是临界资源?什么又是竞态条件?我们接下来给大家一一解释。
2.3 专有名词解释
2.3.1 临界资源
临界资源:一次只允许一个线程访问的资源称为临界资源 。
2.3.2 临界区
临界区:线程对于访问临界资源的代码块称为临界区。
2.3.3 竞态条件
竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
3、synchronize锁的原理
大家看完上文肯定知道了synchronize锁能保证多线程对于临界资源的访问不会发生线程安全问题,那么它的原理是怎么样的呢?
Java对象头:由Mark word和klasspointer两部分组成,如果是数组,还包括数组长度
我们主要来看看Mark word这一部分,以下两个图分别是32位虚拟机和64位虚拟机的Mark word结构
32位虚拟机:
64位虚拟机:
解释:
后两位表示状态
State代表对象状态 Normal是正常状态,Biased是偏向锁状态,Lightweight Locked是轻量级锁状态,Heavyweight Locked是重量级锁状态,Marked for GC指被JVM垃圾回收。以上从Biased->Lightweight Locked->Heavyweight Locked是我们接下来要说的synchronize锁升级过程,大家看不懂可以先记着。
3.1 锁升级过程
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争的增加,只能锁升级,不能降级
3.1.1 偏向锁
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要同步操作:
当锁对象第一次被线程获得的时候进入偏向状态,标记为 101,同时使用 CAS 操作将线程 ID 记录到 Mark Word。如果 CAS 操作成功,这个线程以后进入这个锁相关的同步块,查看这个线程 ID 是自己的就表示没有竞争,就不需要再进行任何同步操作
我们从上图State为Biased状态的Mark word结构可以看出,有一个thread字段,这个是用来存储对该对象加锁的线程的信息。
总的来说偏向锁就是:某个对象第一次被某线程加锁时,会将自己对象头上的Mark word里面的东西改为我们上图说的State为Biased状态的东西,所以当下次同一线程再次来加锁时,发现这个对象Mark word里面存的线程是自己,那就不用去进行一系列加锁流程了,能够提高效率。
但是当此时另一个线程来加锁时,发现这个对象在此之前已经成为偏向其他线程了,那么就会进行锁升级的过程,升级为轻量级锁。
3.1.2 轻量级锁
轻量级锁:一个对象有多个线程要加锁,但加锁的时间是错开的(没有竞争),可以使用轻量级锁来优化。
当某个对象对应的锁升级为轻量级锁时,会将自己Mark word状态转为State对应的Lightweight Locked那一行,此时会发生如下过程:
①创建锁记录(Lock record)对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的 Mark Word。(锁记录对象结构图如下)
可见,锁记录对象包含了两部分:锁记录对象地址和对象引用部分。
②让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录。
此时会发生两种情况:
2.1 CAS成功:
如果 CAS 替换成功,对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁。
2.2 CAS失败:说明此时对象已经被某个线程加锁了,此时又分为两种情况:
第一种情况:是同一线程对对象加锁。
由于我们知道synchronize是可重入锁,因此会发生如下过程:线程发现自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数
可重入锁:指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。
synchronized 和 ReentrantLock 都是可重入锁。
第二种情况:不是同一线程加锁。
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程。
锁膨胀:在尝试加轻量级锁的过程中,CAS 操作无法成功,可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁 。
③锁解锁过程
-
当退出 synchronized 代码块(解锁时)
-
如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
-
如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
-
成功,则解锁成功
-
失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
-
-
3.1.3 重量级锁
在说synchronize升级为重量级锁是如何保证线程安全问题之前,我们先来说一个东西:Monitor
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,Monitor 也是 class,其实例存储在堆中,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针,这就是重量级
工作流程:
-
开始时 Monitor 中 Owner 为 null
-
当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中(轻量级锁部分详解)
-
在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList BLOCKED(双向链表)
-
Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
-
唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
-
WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)
注意:
-
synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
-
不加 synchronized 的对象不会关联监视器,不遵从以上规则
3.1.4 总体过程
可见当我们使用synchronize对某个对象上锁时多线程访问过程如下:
①某个对象第一次被某个线程上锁时,会从无锁(Normal)状态升级为Biased(偏向锁状态),接下来该线程再对该对象上锁时,可以直接访问。
(此时仅有一个线程对该对象上锁,所以可以理解成偏向锁是在只有一个线程对某个对象上锁时出现)
②当其他线程来对该对象上锁时,发现这个对象是偏向其他线程,因此会升级成轻量级锁,在解锁后该对象会变回无锁状态或者偏向锁状态(这个和批量重定向、批量撤销有关)。
(此时虽然有多个线程对该对象上锁,但是他们并没有发生竞争,因此可以理解成轻量级锁是在有多个线程上锁,但是没有发生竞争的情况下出现)
批量重偏向:当撤销偏向锁阈值超过 20 次后,JVM 会觉得是不是偏向错了,于是在给这些对象加锁时重新偏向至加锁线程。
批量撤销:当撤销偏向锁阈值超过 40 次后,JVM 会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
③当多个线程对该对象加锁,并且发生竞争时,会升级为重量级锁。
(此时有多个线程对该对象上锁,并且发生了竞争,因此可以理解成重量级锁是发生在有竞争的情况下)
注意事项:
一个对象创建时:
-
如果开启了偏向锁(默认开启),那么对象创建后,MarkWord 值为 0x05 即最后 3 位为 101,thread、epoch、age 都为 0
-
偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
-XX:BiasedLockingStartupDelay=0
来禁用延迟。JDK 8 延迟 4s 开启偏向锁原因:在刚开始执行代码时,会有好多线程来抢锁,如果开偏向锁效率反而降低 -
当一个对象已经计算过 hashCode,就再也无法进入偏向状态了
-
添加 VM 参数
-XX:-UseBiasedLocking
禁用偏向锁
撤销偏向锁的状态:
-
调用对象的 hashCode:偏向锁的对象 MarkWord 中存储的是线程 id,调用 hashCode 导致偏向锁被撤销(这里很简单理解,大家去看看上面那个图,只有State状态为Normal,才有hashcode)
-
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
-
调用 wait/notify,需要申请 Monitor(只有重量级锁才有monitor),进入 WaitSet
3.2 锁优化
从上述synchronize的上锁过程大家可以发现是比较复杂、繁琐的,因此JVM对synchronize锁进行了一系列的锁优化操作。
3.2.1 自旋锁
重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁
注意:
-
自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势
-
自旋失败的线程会进入阻塞状态
优点:不会进入阻塞状态,减少线程上下文切换的消耗
缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源
3.2.2 锁粗化
对相同对象多次加锁,导致线程发生多次重入,频繁的加锁操作就会导致性能损耗,可以使用锁粗化方式优化
如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部
-
一些看起来没有加锁的代码,其实隐式的加了很多锁:
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
-
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,转化为 StringBuffer 对象的连续 append() 操作,每个 append() 方法中都有一个同步块
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,只需要加锁一次就可以。
3.2.3 锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM 即时编译器的优化锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)
4. synchronize的基本使用
4.1 使用方式
synchronize可以锁静态方法、成员方法、某个对象、某个类。
4.1.1 synchronize锁某个对象
public class Test20 {
public static int a=0;
public static myObject object=new myObject();
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程1对a进行50000次a++
synchronized (object){ //给object对象加上synchronize锁
a++;
}
}
}
});
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程2对a进行50000次a--
synchronized (object){ //给object对象加上synchronize锁
a--;
}
}
}
});
t1.start();
t2.start();
//等待t1 t2线程运行完后输出a的值
t1.join();
t2.join();
System.out.println(a);
}
}
class myObject{
public static void method1(){
System.out.println("加在静态方法上");
}
public void method2(){
System.out.println("加在成员方法上");
}
}
可见,以上是给object这个对象加锁,保证了每次只有一个线程能访问synchronize锁住的代码块,因此线程安全。
4.1.2 synchronize锁某个类
public class Test20 {
public static int a=0;
public static myObject object=new myObject();
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程1对a进行50000次a++
synchronized (myObject.class){ //给myObject类加上synchronize锁
a++;
}
}
}
});
Thread t2=new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) { //线程2对a进行50000次a--
synchronized (myObject.class){ //给myObject类加上synchronize锁
a--;
}
}
}
});
t1.start();
t2.start();
//等待t1 t2线程运行完后输出a的值
t1.join();
t2.join();
System.out.println(a);
}
}
class myObject{
public static void method1(){
System.out.println("加在静态方法上");
}
public void method2(){
System.out.println("加在成员方法上");
}
}
4.1.3 synchronize锁某个类的成员方法
class myObject{
public static void method1(){
System.out.println("加在静态方法上");
}
public synchronize void method2(){
System.out.println("加在成员方法上");
}
}
这种其实就等价于给对象加锁,因为我们synchronize锁有竞争时是需要创建monitor的,每个类或者每个对象对应唯一monitor,因为成员方法属于对象,因此给成员方法加锁实际上就是给对象加锁。
4.1.4 synchronize锁住某个类的静态方法
class myObject{
public synchronize static void method1(){
System.out.println("加在静态方法上");
}
public void method2(){
System.out.println("加在成员方法上");
}
}
这种实际上等价于给类加锁,因为静态方法属于类,因此给静态方法加锁实际上就是给类加锁。
注意,monitor是和对象或者类一一对应的,类的monitor和对象的monitor是不同的monitor,比如上述myObject类对应一个monitor,而myObject类不同的对象对应不同的monitor。
4.1.5 wait/notify
需要获取对象锁后才可以调用 锁对象.wait()
,notify 随机唤醒一个线程,notifyAll 唤醒所有线程去竞争 CPU
Object 类 API:
public final void notify():唤醒正在等待对象监视器的单个线程。 public final void notifyAll():唤醒正在等待对象监视器的所有线程。 public final void wait():导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll()方法。 public final native void wait(long timeout):有时限的等待, 到n毫秒后结束等待,或是被唤醒
说明:wait 是挂起线程,需要唤醒的都是挂起操作,阻塞线程可以自己去争抢锁,挂起的线程需要唤醒后去争抢锁
对比 sleep():
-
原理不同:sleep() 方法是属于 Thread 类,是线程用来控制自身流程的,使此线程暂停执行一段时间而把执行机会让给其他线程;wait() 方法属于 Object 类,用于线程间通信
-
对锁的处理机制不同:调用 sleep() 方法的过程中,线程不会释放对象锁,当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池(不释放锁其他线程怎么抢占到锁执行唤醒操作),但是都会释放 CPU
-
使用区域不同:wait() 方法必须放在同步控制方法和同步代码块(先获取锁)中使用,sleep() 方法则可以放在任何地方使用
底层原理:
-
Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
-
BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
-
BLOCKED 线程会在 Owner 线程释放锁时唤醒
-
WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争