Java锁机制(Synchronized)
Posted 皓洲
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java锁机制(Synchronized)相关的知识,希望对你有一定的参考价值。
Java锁机制(Synchronized)
JVM内存结构
在了解Java锁机制前,先来复习一下JVM的内存结构
对象、对象头、结构
Java的对象包含了三个部分:对象头、实例数据、对齐填充字节。
-
对齐填充字节是为了满足Java对象的大小必须是8bit的倍数这一条件而设计的。
-
实例数据就是在你初始化对象时,设定的属性和状态的内容(属性和方法)。
-
对象头存放了一些对象本身运行时的信息,对象头包含了两部分:Mark Word和Class Pointer。相较于实例数据,对象头属于一些二外的存储开销,所以它被设计的极小,来提高效率。
- Class Pointer就是一个指针,它指向了当前对象类型所在的方法区中的类型数据。
- Mark Word存储了很多和当前对象运行时锁状态有关的数据(重点)
通过这张表我们可以看到Mark Word只有32bit,并且它是非结构化的。这样在不同的锁标志位下,不同的字段而已重用不同的比特位,因此达到节省空间的作用。
我们先关注Mark Word的最后两位,这两位代表了锁标志位,分别对应着:无锁、偏向锁、轻量级锁、重量级锁这四种状态。
Synchronized
大家都知道在Java中synchronized关键字可以用来同步线程。synchronized被编译后会生成monitorenter和monitorexit两个字节码指令,依赖这两个字节码指令来进行线程同步。
我们写一段验证代码
public class TestSync {
private int num=0;
public void test(){
for(int i=0;i<1000;i++){
synchronized (this) {
System.out.println("thread: " + Thread.currentThread().getId() + " num = " + num++);
}
}
}
public static void main(String[] args) {
TestSync sync = new TestSync();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
sync.test();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
sync.test();
}
});
t1.start();
t2.start();
}
}
通过javac 编译java文件,再用javap -c 反编译class文件,就可以得到字节码。
我们可以看到monitorenter和monitorexit将我们的核心代码进行了包裹,说明验证成功。
Monitor常常被翻译成监视器或者管程,简单来说可以把他想成一个只能容纳一个人的房间,获取对象的线程就是要进入房间的人,一个线程进入了monitor其他人就只能等待,只有当这个线程退出,其他线程才能进入。
synchronized的同步机制
可以看下上面这张图,首先Entry Set中聚集了一些想要进入Monitor的线程,他们都处于Waiting状态,加入某个名为A的线程成功的进入了Monitor,那么他就处于Active。假设此时线程执行途中遇到了一个判断条件,需要让它暂时让出执行权,它就会进入Wait Set,状态也会标记为waiting,此时Entry Set中的线程B就有机会进入到Monitor。如果线程B进入Monitor中运行完代码之后,它就会通过notify的形式来唤醒Wait Set中的A线程。
synchronized的性能问题
上面说到synchronized会生成monitorenter和monitorexit两条字节码指令,而Monitor时依赖于操作系统的mutex lock来实现的。Java线程实际上时对操作系统线程的映射,所以每当唤醒或挂起一个线程的时候,都是在操作系统中的用户态和内核态进行切换。这种操作时重量级的,在一些情况下切换时间甚至会超过线程执行任务的时间,这样的话使用synchronized将会对程序的性能产生严重的影响。
锁的四种状态
无锁
顾名思义就是没有对资源进行锁定,所有线程都能访问到同一资源。这就涉及到多种情况
- 无竞争:线程之间不存在竞争直接获取资源就可以了
- 存在竞争:使用非锁方式实现同步线程。这就是我们耳熟能详的CAS(Compare And Swap)
CAS通过操作系统的一条指令来实现,所以它可以保证原子性,通过诸如CAS这种方式,我们就可以进行无锁编程。
偏向锁
上面我们也分析了依赖操作系统的mutex lock导致性能低下的原因,所以在大部分情况下无锁的效率是很高的,但这并非意味着无锁能全面代替有锁。
现在我们给对象开始加锁,假如一个对象被加锁了,但在实际运行时只有一个线程会获取这个对象锁。那么我们最理想的情况就是不通过线程状态切换,也不需要通过CAS来获得锁,因为这多多少少还是会耗费一些资源。我们设想的是最好对象能够认识这个线程,只要是这个线程过来,那么对象直接把锁交出去,我们就可以认为这个锁偏爱这个线程,所以被称为偏向锁,那么偏向锁是如何实现的?
再看到这幅Mark Word的图:
我们先通过判断后三位来判断是否是偏向锁,如果是偏向锁的话,它的前23位就是用来记录偏爱的进程ID。
轻量级锁
如果对象发现目前不知有一个线程,而是有多个线程正在竞争锁,那么偏向锁就会升级成轻量级锁。
当锁的状态还是偏向锁时,是通过Mark Word中的线程id来找到线程,那么当锁的状态升级到轻量级锁的时候,如何判断线程和锁之间的绑定关系呢?
图中给出了答案:通过前30位来指向栈中锁记录的指针。
我们来具体研究一下:
-
当一个线程想要获得某个对象的锁时,加入看到锁的标志位为00,那么就知道它是一个轻量级锁。
-
这时线程会再自己的虚拟机栈中开辟一块被称为Lock Record的空间。
- 关于虚拟机栈,上面JVM内存结构中提到了这是线程私有的。
-
那么Lock Record存放的是什么呢?存放的是对象头中Mark Word的副本,以及owner指针
-
线程通过CAS去尝试获取锁,一旦获得那么将会赋值该对象头中的Mark Word到Lock Record中,并且将Lock Record中的owner指针指向该对象。
-
同时对象的Mark Word的前30位将会生成一个指针,指向虚拟机栈中的Lock Record
-
这样就完成了线程和对象之间的绑定,他们可以互相知道对方的存在。
完成了线程和对象之间的绑定之后,万一有其他线程也想要获取到这个对象怎么办?
- 此时其他需要资源的线程将会进入自选等待(CPU空转)
- 如果长时间进行自旋对CPU来说是一种浪费,于是出现了**”适应性自旋“**的优化。
适应性自旋
自旋时间不再固定,而是由上一次在同个锁上的自旋时间以及锁状态,这两个条件来进行决定。
举个例子:当前正在自旋等待的线程刚刚已经成功获得过了锁,但是锁目前是被其他线程占用,那么虚拟机就会认为这次自旋也很有可能会成功,进而允许更长的自旋时间。
假如此时有一个线程正在进行自旋,那么这个线程将会进行等待。如果同时有多个线程想要获得这个对象锁,也就是一旦自选等待的线程超过一个,那么轻量级锁将会升级为重量级锁。
重量级锁
如果对象锁状态被标记为重量级锁,那么就是和我们最初讲的那样,需要通过Monitor来对线程进行控制,此时将会完全锁定资源,对线程的管控最为严格。
以上是关于Java锁机制(Synchronized)的主要内容,如果未能解决你的问题,请参考以下文章
#yyds干货盘点# Java | 关于synchronized相关理解
java多线程——锁机制synchronized(同步方法)