目录
一、Synchronized 概述
synchronized 是Java 1.5之后引进的一个解决并发编程中原子性、可见性这两个并发特性问题的解决方案,它是Java中基于对象锁实现的并发编程同步关键字,今天我们就结合代码一起看下它是如何解决原子性、可见性问题的,以及它底层的实现原理是什么,同时看下Java 1.6之后,对它的优化措施是什么。
二、Synchronized在并发编程中解决的问题
2.1 解决原子性问题
2.1.1 问题代码
package com.ningzhaosheng.thread.concurrency.features.atom;
/**
* @author ningzhaosheng
* @date 2024/2/5 18:33:27
* @description 原子性测试
*/
public class TestAtom {
private static int count;
// ++操作自增
public static void increment(){
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
2.1.2 执行结果
可见,以上执行结果在多线程环境下,多线程操作共享数据时,预期的结果,与最终执行的结果不符。
通过分析可知,其实count++操作,并不是一个原子性操作,它包含了getstatic、iconst_1、iadd、putstatic四个操作步骤,在多线程执行的过程中,会出现并发问题。
2.1.3 优化代码
package com.ningzhaosheng.thread.concurrency.features.atom.syn;
/**
* @author ningzhaosheng
* @date 2024/2/6 19:14:17
* @description 测试synchronized保证原子性
*/
public class TestSynchronized {
private static int count;
// ++操作自增
public static void increment() {
synchronized (TestSynchronized.class) {
count++;
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
2.1.4 测试结果
我们发现,使用synchronized关键字后,在多线程并发的情况下,执行结果和预期值一致,没有了并发问题。
2.1.5 优化代码分析
为什么使用的synchronized之后,能解决由于原子性问题导致的并发问题呢?要回答这个问题,我们还需要编译代码,看下字节码,看到底synchronized做了些什么操作。
2.1.5.1 编译java源文件程序
2.1.5.2 查看编译文件
javap -v .\TestSynchronized.class
2.1.5.3 分析编译文件
我们可以通过分析编译出来的.class字节码文件,分析添加了synchronized关键字后,做了些什么操作。
通过上图中的字节码我们可以看到,添加synchronized关键字后,在执行count++操作的getstatic、iconst_1、iadd、putstatic等四个操作步骤指令的前后位置分别添加了monitorenter、monitorexit两个线程同步指令。monitorenter指令能使线程获得对象监视器(其实就是对象锁)。monitorexit指令释放并退出对象监视器(其实就是对象锁)。这两个指令的使用能避免多线程同时操作临街资源,并保证同一时间点,只会有一个线程正在操作临界资源。从而避免了并发安全问题。
2.2 解决可见性问题
2.2.1 问题代码
package com.ningzhaosheng.thread.concurrency.features.visible;
/**
* @author ningzhaosheng
* @date 2024/2/5 19:36:39
* @description 测试可见性
*/
public class TestVisible {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
// ....
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
2.2.2 执行结果
由结果可知,主线程修改了flag = false;但是并没有使t1线程里面的循环结束.
2.2.3 优化代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;
/**
* @author ningzhaosheng
* @date 2024/2/5 19:52:31
* @description 测试synchronized
*/
public class TestSynchronized {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
synchronized (TestSynchronized.class) {
//...
}
System.out.println(111);
}
System.out.println("t1线程结束");
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
2.2.4 测试结果
从测试结果可以看出,使用了synchronized同步代码块之后,在主线程中修改了flag=false 之后,线程t1也获取到最新的变量值,结束了while循环。也就是说synchronized也可以解决并发编程的可见性问题。那么synchronized是怎么保证并发编程的可见性的呢,我们接下来分析下。
2.2.5 优化代码分析
2.2.5.1 synchronized 修饰方法
2.2.5.1.1 源代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;
/**
* @author ningzhaosheng
* @date 2024/2/13 10:16:36
* @description synchronized 修饰方法
*/
public class TestSynchronizedMethod {
public static boolean flag = true;
public static synchronized void runwhile() {
while (flag) {
System.out.println(111);
}
System.out.println("t1线程结束");
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
runwhile();
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
2.2.5.1.2 执行结果
2.2.5.1.3 编译分析
javap -v .\TestSynchronizedMethod.class
可以看见,使用synchronized修饰方法后,通过javap -v 查看编译的字节码,会生成一个ACC_SYNCHRONIZED标识符,会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。
可查看官网解析:
Chapter 2. The Structure of the Java Virtual Machine (oracle.com)
该标识符的作用是使当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的方法。
获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。
2.2.5.2 synchronized 修饰代码块
2.2.5.2.1 代码
package com.ningzhaosheng.thread.concurrency.features.visible.syn;
/**
* @author ningzhaosheng
* @date 2024/2/13 10:48:02
* @description synchronized 修饰代码块
*/
public class TestSynchronizedCodeBlock {
public static boolean flag = true;
public static void runwhile() {
while (flag) {
synchronized (TestSynchronizedCodeBlock.class) {
System.out.println(flag);
}
System.out.println(111);
}
System.out.println("t1线程结束");
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
runwhile();
});
t1.start();
Thread.sleep(10);
flag = false;
System.out.println("主线程将flag改为false");
}
}
2.2.5.2.2 执行结果
2.2.5.2.3 编译分析
javap -v .\TestSynchronizedCodeBlock.class
可以看到,使用synchronized修饰代码块后,查看编译的字节码会发现再存取操作静态共享变量时,会插入monitorenter、monitorexit原语指令,关于这两个指令的说明,可查看文档:
Chapter 6. The Java Virtual Machine Instruction Set (oracle.com)
它实现可见性的原理:
当前线程优先获取Monitor对象,同一个时刻只能有一个线程获取到,在当前线程释放Monitor对象之前,其它线程无法获取到同一个Monitor对象,从而保证了同一时刻只能有一个线程进入到被synchornized修饰的代码块。
获取到锁资源之后,会将内部涉及到的变量从CPU缓存中移除,且要求线程必须去主内存中重新拿数据,在释放锁之后,会立即将CPU缓存中的数据同步到主内存。
三、synchronized 底层原理
从上面分析结果可以看出无论是synchronized代码块还是synchronized方法,其线程安全的语义实现最终依赖一个叫monitor的东西,那么这个神秘的东西是什么呢,我们一起来研究下。
3.1 monitor 监视器
3.1.1 monitor 来源
当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?是存在锁对象的对象头中的。
Monitor锁是基于操作系统的Mutex锁实现的,Mutex锁是操作系统级别的重量级锁,所以性能较低。
在Java中,创建的任何一个对象在JVM中都会关联一个Monitor对象,所以说任何一个对象都可以成为锁。
3.1.2 对象头
3.1.2.1 对象头的内存布局
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
3.1.2.2 对象头底层hotspot内存结构
HotSpot采用instanceOopDesc和arrayOopDesc来描述对象头,arrayOopDesc对象用来描述数组类型。
3.1.2.2.1 instanceOopDesc
instanceOopDesc的定义的在Hotspot源码的 instanceOop.hpp 文件中,具体内容如下:
3.1.2.2.2 arrayOopDesc
arrayOopDesc的定义对应 arrayOop.hpp ,具体内容如下:
3.1.2.3 对象头底层hotspot数据结构
在普通实例对象中, oopDesc的定义包含两个成员,分别是 _mark 和 _metadata。
- _mark 表示对象标记、属于markOop类型,也就是接下来要讲解的Mark World,它记录了对象和锁有关的信息。
- _metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中Klass表示普通指针、 _compressed_klass 表示压缩类指针。
对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针,及对象指向它的类元数据的指针。
3.1.2.3.1 Mark word
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。Mark Word对应的类型是 markOop 。源码位于 markOop.hpp 中。
jdk8u/jdk8u/hotspot: 69087d08d473 src/share/vm/oops/markOop.hpp
在 64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
3.1.2.3.2 klass pointer
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。 如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项 - XX:+UseCompressedOops 开启指针压缩,其中,oop即ordinaryobject pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
- 每个Class的属性指针(即静态变量)
- 每个对象的属性指针(即对象变量)
- 普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。对象头 = Mark Word + 类型指针(未开启指针压缩的情况下)在32位系统中,Mark Word = 4 bytes,类型指针 =4bytes,对象头 = 8 bytes = 64 bits;在 64位系统中,Mark Word = 8 bytes,类型指针 = 8bytes,对象头 = 16 bytes = 128bits;
3.1.2.3.3 实例数据
就是类中定义的成员变量。
3.1.2.3.4 对齐填充
对齐填充并不是必然存在的,也没有什么特别的意义,他仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3.1.2.3.5 ObjectMonitor 数据结构
class ObjectMonitor {
// 对象头信息
markOop _header;
// 获取锁的次数
int _count;
// 等待队列
Thread* _waiters;
// 重入次数
int _recursions;
// 所属对象
oop _object;
// 当前拥有锁的线程
Thread* _owner;
// 等待队列
WaitSet _WaitSet;
// 入口队列
EntryList _EntryList;
// 加锁操作
void lock();
// 解锁操作
void unlock();
// 尝试加锁操作
bool try_lock();
// 等待操作
void wait(jlong millis, bool interruptable);
// 通知操作
void notify(Thread* target_thread);
}
有几个重要的属性
- _WaitSet:是一个集合,当线程获到锁之后,但是还没有完成业务逻辑,也还没释放锁,这时候调用了Object类的wait()方法,这时候这个线程就会进入_WaitSet这个集合中等待被唤醒,也就是执行nitify()或者notifyAll()方法唤醒
- _EntryList:是一个集合,当有多个线程来获取锁,这时候只有一个线程能成功拿到锁,剩下那些没有拿到锁的线程就会进入_EntryList集合中,等待下次抢锁
- _Owner:当一个线程获取到锁之后,就会将该值设置成当前线程,释放锁之后,这个值就会重新被设置成null
- _count:当一个线程获取到锁之后,_count的值就会+1,释放锁之后就会-1,只有当减到0之后,才算真正的释放掉锁了,其它线程才能来获取这把锁,synchornized可重入锁也是基于这个值来实现的。
hotspot 源码内容如下:
3.1.3 monitor 对象锁原理
synchronized 内部包括ContentionList、EntryList、WaitSet、在OnDeckOwner、!Owner这6个区域,每个区域的数据都代表锁的不同状态。
- ContentionList:锁竞争队列,所有请求锁的线程都被放在竞争队列中。
- EntryList:竞争候选列表,在ContentionList中有资格成为候选者来竞争锁资源的线程被移动到了 Entry List 中。
- WaitSet:等待集合,调用wait方法后被阻塞的线程将被放在WaitSet中。
- OnDeck:竞争候选者,在同一时刻最多只有一个线程在竞争锁资源,该线程的状态被称为 OnDeck。
- Owner:竞争到锁资源的线程被称为Owner状态线程。
- !Owner:在Owner线程释放锁后,会从Owner的状态变成!Owner。
3.1.3.1 执行流程图
3.1.3.2 执行流程说明
- synchronized 在收到新的锁请求时首先自旋,如果通过自旋也没有获取锁资源,则将被放入锁竞争队列 ontentionList中。
- 为了防止锁竞争时 ContentionList 尾部的元素被大量的并发线程进行 CAS访问而影响性能,Owner 线程会在释放锁资源时将ContentionList中的部分线程移动到EntryList中,并指定EntryList中的某个线程(一般是最先进入的线程)为OnDeck线程。Owner线程并没有直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,让OnDeck 线程重新竞争锁。在Java中把该行为称为“竞争切换”,该行为牺牲了公平性,但提高了性能。
- 获取到锁资源的OnDeck线程会变为Owner线程,而未获取到锁资源的线程仍然停留在 EntryList中。
- Owner线程在被wait方法阻塞后,会被转移到WaitSet队列中,直到某个时刻被notify 方法或者 notifyAll 方法唤醒,会再次进人 EntryList中。ContentionList、EntryList、WaitSet中的线程均为阻塞状态,该阻塞是由操作系统来完成的(在Linux内核下是采用pthread mutexlock内核所数实现的)。
3.1.4 总结
在synchronized中,在线程进人ContentionList 之前,等待的线程会先尝试以自旋的方式获取锁,如果获取不到就进人ContentionList,该做法对于已经进入队列的线程是不公平的,因此synchronized是非公平锁。另外,自旋获取锁的线程也可以直接抢占OnDeck 线程的锁资源。
synchronized是一个重量级操作,需要调用操作系统的相关接口,性能较低,给线程加锁的时间有可能超过获取锁后具体逻辑代码的操作时间。
JDK 1.6对synchronized做了很多优化,引人了适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等以提高锁的效率。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫作锁膨胀。在JDK1.6中默认开启了偏向锁和轻量级锁,可以通过XX:UseBiasedLocking 禁用偏向锁。
四、Synchronized优化
在JDK1.5的时候,Doug Lee推出了ReentrantLock,lock的性能远高于synchronized,所以JDK团队就在JDK1.6中,对synchronized做了大量的优化。
4.1 锁粗化
4.1.1 定义
如果在一个循环中,频繁的获取和释放做资源,这样带来的消耗很大,锁粗化就是将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗。
4.1.2 代码示意
package com.ningzhaosheng.thread.concurrency.features.atom.syn;
/**
* @author ningzhaosheng
* @date 2024/3/10 17:34:21
* @description 测试锁粗化
*/
public class TestSynchronizedExpansion {
// 正常代码
public static void increment1() {
StringBuffer sb = new StringBuffer();
for(int i = 0;i< 100;i++){
synchronized (TestSynchronizedExpansion.class){
sb.append(i+"aa");
}
}
System.out.println(sb.toString());
}
/**
* 锁粗化示意代码
*/
public static void increment2() {
StringBuffer sb = new StringBuffer();
synchronized (TestSynchronizedExpansion.class){
for(int i = 0;i< 100;i++){
sb.append(i+"aa");
}
}
System.out.println(sb.toString());
}
public static void main(String[] args) {
increment1();
}
}
4.2 锁消除
4.2.1 定义
在synchronized修饰的代码中,如果不存在操作临界资源的情况,会触发锁消除,你即便写了synchronized,他也不会触发。
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
4.2.2 示意代码
package com.ningzhaosheng.thread.concurrency.features.atom.syn;
/**
* @author ningzhaosheng
* @date 2024/3/10 17:46:31
* @description 测试锁消除
*/
public class TestSynchronizedRemove {
/**
* 这个方法并没有存在共享资源,即使加了synchronized,也不会触发同步
*/
public static synchronized void increment() {
System.out.println("aaaaaaa");
}
public static void main(String[] args) {
increment();
}
}
4.3 锁升级(锁膨胀)
4.3.1 定义
锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫作锁膨胀。
4.3.2 锁升级过程
- 无锁、匿名偏向:当前对象没有作为锁存在。
- 偏向锁:如果当前锁资源,只有一个线程在频繁的获取和释放,那么这个线程过来,只需要判断,当前指向的线程是否是当前线程 。
如果是,直接拿着锁资源走。
如果当前线程不是我,基于CAS的方式,尝试将偏向锁指向当前线程。如果获取不到,触发锁升级,升级为轻量级锁。(偏向锁状态出现了锁竞争的情况)
- 轻量级锁:会采用自旋锁的方式去频繁的以CAS的形式获取锁资源(采用的是自适应自旋锁)
如果成功获取到,拿着锁资源走
如果自旋了一定次数,没拿到锁资源,锁升级。
- 重量级锁:就是最传统的synchronized方式,拿不到锁资源,就挂起当前线程。(涉及用户态和内核态的切换)
好了,本次内容就分享到这,欢迎关注本博主。如果有帮助到大家,欢迎大家点赞+关注+收藏,有疑问也欢迎大家评论留言!