查看原文
其他

详解 Synchronized 关键字,多线程一点都不可怕!

编程导航 编程导航 2024-01-30

关于 Java 的多线程知识可能是许多人的难点,而 Synchronized 很多人只知道能够使用这个关键字能保证多线程安全,却有很多知识点会遗漏,最近,编程导航星球的一位鱼友发布了关于 Synchronized 关键字的详细教程解释。

作者:LYX,编程导航星球 编号 26376

一、Synchronized 关键字的使用位置有哪些?

1.直接在方法上使用 Synchronized 关键字,可以是实例方法也可以是静态方法。如果是实例方法,对象锁是当前实例对象(this)等价于  synchronized(this) 的对象锁。如果是静态方法锁对象是当前类对应的 Class对象等价于 synchronized(当前类名.class)的对象锁。

实例方法使用 synchronized 代码:

public synchronized void testMethod() {

}

静态方法使用 synchronized 代码:

public static synchronized void testMethod() {

}

2.修饰一段代码块,格式为:

synchronized(对象锁){

//需要被同步的代码块

}

此时对象锁需要手动去指定,有以下三种类型对象锁。

synchronizedthis) //对象锁是当前实例对象

X x = new X()

synchronized(x)  //对象锁是x对象

synchronized(X.class)  //对象锁是X类对应的Class对象

ps:(这里就没有代码详细介绍了,本篇重点在第二和第五部分)

二、Synchronized关键字的工作原理

当对一段代码使用 synchronized 关键字修饰后,会绑定上对应锁对象的监视器对象。在 Java 中每个 Java 对象都对应一个监视器(Monitor)对象,该监视器对象中包含了一个计数器和等待队列。当一个线程访问到代码块后,发现被 synchronized 同步过,就会查看锁对象对应的监视器对象中的计数器,如果是 0 说明没有被线程占用,如果不是 0 说明被其他线程占用便进入等待队列。线程成功进入代码块之后,便将计数器加 1。线程从代码块出去之后,计数器便减 1。如果计数器减为 0 说明锁被释放了,这个时候在等待队列的其他线程就可以进来进行访问了。

总结:Java 对象锁中判断对象锁是否被释放或者占用的规则,判断监视器对象中的计数器是否为 0。

ps:线程是如何判断一段代码是否被 synchronized 同步过请参考后文第四节。


ps:这个计数器是否可以一直加加呢?我们接着往下看:

三、Synchronized 关键字可重入锁的的实现原理

上文讲到线程进入同步代码块之后计数器会被加 1,如果在同步代码块中又有一个 synchronized 修饰的同步代码块,而且它的对象锁还是同一个对象锁。进入到这个代码块计数器会再次加 1。这个过程就是重入锁。“可重入锁”就是指可以再次获取自己的内部锁。然后这个线程依次离开这两个代码块计数器依次减一减一,最后计数器为 0 释放锁。

四、Synchronized 在字节码指令中的原理

4.1synchronized 修饰方法的字节码指令原理

在方法上使用 synchronized 关键字实现同步的原因是使用 flag 标记 ACC_SYNCHRONIZED,当调用方法时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否设置,如果设置了,执行线程先尝试获取对象锁(判断计数器是否为 0,为 0 则可以获取对象锁),如果获取到了对象锁,便再去执行方法,最后在方法完成时释放锁。

测试代码如下:


在 cmd 中使用命令 javap 来将 class 文件转换成字节码指令,参数-v表示输出附加信息,参数 -c 表示对代码进行反汇编。

使用 javap.exe 命令如下:

javap -c -v Mytest.class

生成这个 class 文件对应的字节码指令,指令的核心代码如下:

 

在反编译的字节码指令中对 public synchronized void testMethod() 方法使用了 flag 标记 ACC_SYNCHRONIZED,说明此方法是同步的。

4.2synchronized 修饰代码块的字节码原理

如果使用 synchronized 修饰代码块,会在同步代码块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。测试代码如下:

 

在 cmd 中使用指令:

javap -c -v Mytest.class

生成这个 class 文件对应的字节码指令,指令的核心代码如下:


由代码可知,在字节码中使用了monitorenter 和 monitorexit 指令进行同步处理。

五、Synchronized 使用不同类型的对象锁对应的线程同步情况

5.1

Synchronized(this),多个线程执行以下情况都是互相同步的状态:

1.执行synchronized(this){}同步代码块

2.执行this所指对象中的用synchronized 修饰的实例方法

3.执行synchronizedthis所指对象){}同步代码块

代码测试如下:


根据三种情况分别定义 3 个 runnable 任务



开启三个线程进行测试


测试结果如下:


从测试结果可以看出线程执行这三种情况下的代码都是同步执行的。

Thread-0 执行情况 1(synchronized(this){}同步代码块)时,线程 Thread-1 和 Thread-2 进入等待队列等待。Thread-0 执行结束,释放锁。然后就是 Thread-2 执行情况 3(synchronized(this所指对象){}同步代码块), Thread-1 在等待队列等待。Thread-2 执行结束,释放锁。最后就是 Thread-1 执行情况 2(执行 this 所指对象中的 synchronized 关键字修饰的实例方法)。

5.2

Synchronized(非 this 对象 x),多个线程执行以下情况都是互相同步的状态:

1.执行 synchronized(非this对象x){}同步代码块。

2.执行 x 对象中 synchronized 修饰的实例方法是呈同步效果。

3.执行 x 对象方法里面的 synchronized(this){} 代码块时也呈现同步效果。

这个和 5.1 所说的意思相同,就是反过来进行描述。

5.3

每一个 Java 类都对应一个 (Class类) 的实例对象,这个对象在内存中是单例的。所以如果使用 synchronized(X类名.class) 作为对象锁,每个 Java 类只有一个这样对应的对象锁。

Synchronized(X类名.class),多个线程执行以下情况都是互相同步的状态:

1.执行 synchronized(X类名.class){} 同步代码块

2.执行 x 对象的 synchronized 修饰的静态方法,记住这里的 x 对象包含所有的 X 对象实例。比如 X x1 = new X(),X x2 = new X()。在这个时候 x1 对象的静态方法和 x2 对象的静态方法调用以及所有使用 synchronized 修饰的 (X类名.class){} 的代码块都是互相同步的。所以这里算是一个使用 synchronized(X类名.class) 作为对象锁的一个陷阱,一不小心就遗漏了。

因为 Java 中每个类对应的 Class 对象是单例的所以才会出现上面这么复杂的情况。

这里引发一个思考?

是不是只要多线程操作一个单例对象就要考虑是否会出现线程不安全问题?一个线程对应一个对象不会出现线程不安全问题。但是如果这个对象是单例的那么就是多个线程对应一个对象就可能会有线程不安全问题。

单例对象可能来源于:

1.jdk 原生的系统类,可能某个原生的类返回的对象是单例的。

2.通过 @Bean 注解返回的对象

3.自己手写单例模式返回的对象。

这里又引发一个思考?

在 Java 中对象存在单例的,那么是否存在其他单例的东西。比如“单例方法”、“单例变量”。

还真存在“单例方法”“单例变量”。static 关键字修饰的方法就是“单例方法”,因为实例方法是 new 一个对象对应一个实例方法的内存,但是 static 静态方法是不管你这个类 new 多少个对象对应的都是这个类中同一块内存中的静态方法。所以我这里称之为“单例方法”。

再思考一下

既然是静态方法和静态变量是单例的,那么多线程访问它们是不是就要考虑是否会出现线程不安全问题?

为什么它们是单例的就要考虑线程安全问题?

因为它们是单例的,那么在多线程访问时对于多个线程来说它们就是共享资源。既然是多线程操作共享资源那么就需要考虑线程安全问题。

多线程操作共享资源会有什么问题?

有很多问题例如数据覆盖,脏读,数据可见性等等。

再问一个问题,既然使用 sychronized 解决了上个问题中说的数据覆盖,脏读,数据可见性问题,那么使用 sychronized 会有什么问题吗?

例如线程阻塞导致的等待时间过长,还有死锁等等。尤其是线程阻塞这一点,synchronized 在刚诞生之初使用 synchronized 对程序性能影响是很大的,尤其是在 JDK5 之前。在 JDK6 有专门针对 synchronized 进行过性能优化。如自旋锁、轻量级锁、偏向锁等。

总结一下

什么时候需要使用 sychronized 去同步代码?

在多线程操作共享资源的时候需要用到 sychronized 关键字。(这里延伸一下,准确地说是什么时候需要添加同步配置,因为使用 synchronized 只是同步配置中的一种,让 java 代码变得同步还有很多种配置方法,例如:Lock类,Cas,Volatile 关键字,分布式锁,如果你写的 java 代码中有操作 mysql 数据库,本身 mysql 中也提供了大量的同步实现方法。但是具体使用哪种锁方式就要根据实现难易程度,各自锁的优缺点,具体场景,自己实验测试,团队理解成本等等方面考虑)

上一个问题说了共享资源要用到 synchronized 关键字,那么在 Java 中哪些是共享资源?

1.多线程操作的是同一个对象,在同一个对象中的实例变量实例方法

2.单例模式返回的对象实例。单例模式下返回的对象满足第一点,多线程操作的是同一个对象。

3.单例对象可能来源于:

  1. jdk 原生的系统类,可能某个原生的类返回的对象是单例的。

  2. 通过@Bean 注解返回的对象

  3. 自己手写单例模式返回的对象。

4.单例方法,单例变量:例如 static 修饰的方法和变量

备注:在@controller 类下写的代码一定是被多线程访问的。所以这也是一个在@controller 类下写代码的注意事项。前面我关于伙伴匹配系统加锁的笔记有说只要是@controller 下写的代码一定都会是被多线程访问的。因为@controller 里面的代码会被多线程执行,如果我们在@controller 里面操作的对象都是来一个线程现场 new 一个对象没有关系,但是如果使用的对象是单例对象就要注意了。

那此时如何检验一个对象、一个静态成员(方法、变量)是否是线程安全呢?

  1. 阅读代码,比如你会发现我们很常用的 System.out.println() 方法就有加 synchronized 进行同步。

    是不是很惊奇!平常一直用的方法居然还有加同步配置。为什么要加?因为 System.out 是一个静态变量(下面有源码截图)那么对于多线程来说它就是共享资源。只要是共享资源就要去衡量这个要不要加同步配置。(这里延伸一下同步配置不止有 synchronize 关键字,还有很多例如 Volatile 关键字,Lock类,CAS,分布式锁,如果是对mysql 操作过程的同步 mysql 也要很多方法实现同步也不一定要使用 synchronized,但是具体使用哪种锁方式就要根据实现难易程度,各自锁的优缺点,具体场景,自己实验测试,团队理解成本等等方面考虑)

下图代码是 System.out.println() 方法中加的同步配置,这里使用的是 synchronized 来进行同步


如下所示 System.out 被 static 修饰是一个静态变量


2.实验测试一个对象是否是线程安全的

比如测试这里的 hashmap 对象是否是线程全的:


按理来说这里大小应该是 20000,但是实际并不是 20000,比 20000 小。这就说明这里的 hashmap 对象不是线程安全的。

3.查百度,看官网,直接问有经验的人,参考项目历史版本中的代码。

六、什么时候使用 Synchronized 关键字

使用 Synchronized 关键字,是为了让代码在多线程环境下同步执行。所以需要牢牢记住“共享”这两个字。只有共享资源的写访问才需要同步化,如果不是共享资源,那么就没有同步的必要。如果多线程对共享资源访问了,只是读没有涉及到对数据的写,那么就没有同步的必要。

七、Synchronized 使用 String 字符串常量作为对象锁的坑

JVM 具有 String 常量池的功能,所以如果使用字符串常量作为锁对象建议这样使用:

String  str  =  new  String(“a”);

synchronized(str){

//需要同步执行的代码

}

不建议这样使用

String  str  =  “a”;

synchronized(str){

//需要同步执行的代码

}

八、在 Synchronized 修饰的代码中,什么时候会释放锁

两种情况:

1.自然释放,就是正常一个线程结束完方法

2.出现异常,一个线程执行方法过程中如果出现异常也会释放锁

九、继承环境下的 Synchronized 的使用情况

父类

实例方法 A,该方法有使用 synchronized 修饰。

子类继承父类

新定义实例方法 B,该方法有使用 synchronized 修饰。

这个时候子类下的方法 A 和方法 B 多线程下操作是同步执行的,即子类名 子类对象名 = new 子类() ,使用子类对象名.方法A()和子类对象名.方法B()是同步执行的。

如果子类中重写了父类的方法 A,必须要自己再单独去添加 synchronized 修饰。否则没有同步。这样设计的原因是为了代码可读性。

十、使用 synchronized 造成的死锁问题以及如何使用 jdk 命令查看死锁状态

Java 线程死锁是一个经典的多线程问题,因为不同的线程都在等待根本不可能被释放的锁,导致所有的任务都无法继续完成。在多线程技术中,“死锁”是必须要避免的,因为这会造成线程的“假死”。

代码实现:


定义两个 runnable



进行测试:


程序运行结果:


从程序运行结果来看程序进入了死锁状态。

可以使用 JDK 自带的工具来检测是否有死锁现象

先使用 jps 获取运行的 id


然后使用 jstack -l 73640 命令,如下监测出死锁现象


死锁是程序设计的 bug,在设计程序时就要避免双方持有对方的锁,只要互相等待对方释放锁,就有可能出现死锁。

备注:本篇只代表个人理解,可能有理解不到位的地方。如有错误请指正。

往期推荐

沉淀 700 天,这份编程学习指南 2.0 发布!

考完研心里没底,想找找工作。。我该怎么办?

很痛苦,脱离视频教程就不会敲代码了。。。

使用第三方服务(宝塔)快速部署项目

2 分钟,帮你写出满分简历!

大一开始,我经历了觉醒与顿悟!

继续滑动看下一个

详解 Synchronized 关键字,多线程一点都不可怕!

编程导航 编程导航
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存