> 文章列表 > synchronized从入门到踹门

synchronized从入门到踹门

synchronized从入门到踹门

synchronized是什么

总所周知,技术是把双刃剑。并发提高了程序的执行效率,同时也带来了许多并发安全问题,而解决并发安全问题的关键,在于维护并发下的三大特性,分别是:原子性,可见性,有序性。

原子性是这篇文章要解决的关键点,而synchronized就可用来解决并发场景下的原子性问题。

仔细阅读下面程序,思考运行结果是什么?

public class Main {private static int i = 0;public static void main(String[] args) throws InterruptedException {Main main = new Main();Thread a = new Thread(() -> {main.add10K();}, "A");  // 线程AThread b = new Thread(() -> {main.add10K();}, "B");  // 线程Ba.start();  // 启动线程Ab.start();  // 启动线程Ba.join();   // 等待线程A执行完毕b.join();   // 等待线程B执行完毕System.out.println(i); // 打印i的值,期望20000}// +10000操作public void add10K(){for (int j = 0; j < 10000; j++) {i++;}}
}

答案:小于20000。

请耐心看分析,看之前也可以先思考为什么会是小于20000这个答案。

从操作系统的角度思考,一条CPU指令是具有原子性的。问题也就是出现在这,i++在操作系统层面,并不仅是一条CPU指令,而是三条。

  1. 读取i的值;

  1. 对i进行+1操作;

  1. 装载i的值。

注意:下面的分析默认都是基于单核(多核其实也是同个原理)

并发,听起来很高级,其实就是操作系统划分了多个很小的时间片给到每个线程,时间片的轮转小到你看起来好像是同时执行的(例如:一边跟女朋友视频,一边给男朋友发信息,其实都是时间片的切换而已,并不是同时执行的),这就是并发的概念。

synchronized从入门到踹门

上面的程序,发生的并发安全问题为:

  • 当线程A执行第2步的时候(对i进行+1操作),此时操作系统将资源分给了线程B

  • 线程B此时读到i的值为0(因此线程A还没将i+1写回主内存),线程B对i进行了+1,然后将i=1的值写回主内存

  • 此时CPU资源来到线程A,线程A将刚才i+1的值写回主内存(i=1)

期望i的值应该为2,却由于线程的上下文切换,导致i++被分开执行,这就是并发场景下的原子性问题。

文章开头已经提到用synchronized可以解决原子性问题。那么,synchronized是如何解决原子性问题的呢?

经过分析可知,问题是出现在线程的上下文快速切换,以至于某些线程对共享变量的值进行操作,来不及及时更新到主内存造成的。

synchronized是利用管程的概念,在操作共享变量时,将共享变量锁住,等待操作完毕后,再释放。

可能这个解释会有点晦涩,且synchronized的锁操作是隐式的,需要查看Java汇编指令才可以看到真正的锁操作,下面演示一下将使用synchronized关键字的代码转换为Java汇编指令。


public class Main {public static void main(String[] args) throws InterruptedException {}public void operate(){synchronized(this){}}}

(该汇编指令通过Javap命令得到)

synchronized从入门到踹门

图中monitorenter、monitorexit、monitorexit,就是synchronized的两个隐式(锁)指令。monitorenter代表加锁,monitorexit代表解锁。

为什么monitorexit有两个呢?正常情况下一个解锁就可以了,万一程序在解锁指令前发生异常,那共享资源会被持续锁住,其他线程将永远无法得到。因此需要在程序异常时再设置一条解锁指令来应对上面的问题。


synchronized作用范围

锁非静态方法

public class Main {public static void main(String[] args) throws InterruptedException {}public synchronized void operate(){}}

该程序的锁作用于非静态方法。该方法来源某个实例,根据传递原则,锁的作用范围就是new出来的那个实例。下面有个坑,看看你是不是真的理解了这个作用于非静态方法的锁🤭。

public class Main {public static void main(String[] args) throws InterruptedException {A a = new A();B b = new B();a.addMoney(b.money);}}class A {public Integer money = 100;public synchronized void addMoney(Integer targetMoney){money += targetMoney;System.out.println(money);}}class B {public Integer money = 200;}

提问:假设在执行addMoney方法时,恰好另一个线程修改了B的money为300,那么addMoney执行的结果是什么呢?

评论区留答案,让我看看你是否真的理解了。


非静态代码块

public class Main {public static void main(String[] args) throws InterruptedException {A a = new A();B b = new B();a.addMoney(b.money);}}class A {public Integer money = 100;public void addMoney(Integer targetMoney){synchronized(this){money += targetMoney;System.out.println(money);}}}class B {public Integer money = 200;}

跟锁非静态方法其实是一样的,锁的作用范围是实例。


public class Main {public static void main(String[] args) throws InterruptedException {A a = new A();B b = new B();a.addMoney(b.money);}}class A {public Integer money = 100;public void addMoney(Integer targetMoney){synchronized(Main.class){money += targetMoney;System.out.println(money);}}}class B {public Integer money = 200;}

这个与上面程序唯一不一样的地方是锁的对象,作用于Main.class,说明锁的Main类(与该类有关的类变量和类方法都会被锁住)。

锁静态方法

public class Main {public static void main(String[] args) throws InterruptedException {A a = new A();B b = new B();a.addMoney(b.money);}}class A {public Integer money = 100;public static void addMoney(Integer targetMoney){synchronized(Main.class){}}}class B {public Integer money = 200;}

这个与锁类一样,作用的是类,解释如上。


synchronized的优化

JDK1.6版本对synchronized进行了优化,为了提升性能,将锁调整为具有可升级特性。具体升级表现为:无锁-->偏向锁-->轻量级锁-->重量级锁。

无锁

编译器会判断加了synchronized关键字的代码是否真的会发生并发安全。如果不会发生,JVM会将synchronized优化掉,变成无锁化。【如下程序,只对i进行读操作,并不会发生并发安全】

public class Main {public static void main(String[] args) {A a = new A();a.readI();}}class A {public Integer i = 100;public synchronized void readI(){System.out.println(i);}}

查看Java汇编指令,发现被优化掉synchronized这个同步锁

synchronized从入门到踹门

偏向锁

偏向锁针对的是某一时刻只有一个线程在频繁的操作,因此JVM在实例的对象头直接把偏向锁ID设置为该线程的ID。只要是你这个家伙来访问这个变量,我可以提供类似无锁一样的操作。

开启偏向锁【默认关闭偏向锁】:-XX:+UseBiasedLocking,偏向锁开启后,默认是4秒才会生效

synchronized从入门到踹门

没有等4秒直接用,没使用到偏向锁(non-biasable)

public class Main {public static void main(String[] args) throws InterruptedException {//        TimeUnit.SECONDS.sleep(5);A a = new A();new Thread(()->{a.writeI();}).start();// 打印一下加锁后的实例a的对象头信息System.out.println(ClassLayout.parseInstance(a).toPrintable());}}class A {public Integer i = 100;public synchronized void writeI(){i += 1;}}
synchronized从入门到踹门

对象头的打印可使用ClassLayout工具,maven依赖如下:

<dependencies><!--查看对象头工具--><dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.9</version></dependency><dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.16</version></dependency>
</dependencies>

等待4秒,使用偏向锁,value为偏向锁ID

public class Main {public static void main(String[] args) throws InterruptedException {TimeUnit.SECONDS.sleep(5);A a = new A();new Thread(()->{a.writeI();}).start();// 打印一下加锁后的实例a的对象头信息System.out.println(ClassLayout.parseInstance(a).toPrintable());}}class A {public Integer i = 100;public synchronized void writeI(){i += 1;}}
synchronized从入门到踹门

轻量级锁

偏向锁其实指的是一般都是某个线程进行变量操作,但是实际场景其实是有多个线程进行操作的,因此在其他线程检查操作的对象头不是自己的ID时,通过CAS尝试再次获取锁,获取不到则转变成轻量级锁,获取到了就还是偏向锁。

重量级锁

多个线程同时操作同个共享资源会升级为重量级锁。


以上则是synchronized的所有概述,欢迎共勉。

历史知识