Java锁机制(Synchronized)

Posted 皓洲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java锁机制(Synchronized)相关的知识,希望对你有一定的参考价值。

Java锁机制(Synchronized)

JVM内存结构

在了解Java锁机制前,先来复习一下JVM的内存结构

image-20210718142659781

对象、对象头、结构

Java的对象包含了三个部分:对象头、实例数据、对齐填充字节。

image-20210718152058413

  • 对齐填充字节是为了满足Java对象的大小必须是8bit的倍数这一条件而设计的。

  • 实例数据就是在你初始化对象时,设定的属性和状态的内容(属性和方法)。

  • 对象头存放了一些对象本身运行时的信息,对象头包含了两部分:Mark Word和Class Pointer。相较于实例数据,对象头属于一些二外的存储开销,所以它被设计的极小,来提高效率。

    • Class Pointer就是一个指针,它指向了当前对象类型所在的方法区中的类型数据。
    • Mark Word存储了很多和当前对象运行时锁状态有关的数据(重点)

image-20210718153610023

​ 通过这张表我们可以看到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文件,就可以得到字节码。

image-20210718162152598

我们可以看到monitorenter和monitorexit将我们的核心代码进行了包裹,说明验证成功。

Monitor常常被翻译成监视器或者管程,简单来说可以把他想成一个只能容纳一个人的房间,获取对象的线程就是要进入房间的人,一个线程进入了monitor其他人就只能等待,只有当这个线程退出,其他线程才能进入。

image-20210718162704914

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将会对程序的性能产生严重的影响。

锁的四种状态

无锁

顾名思义就是没有对资源进行锁定,所有线程都能访问到同一资源。这就涉及到多种情况

  1. 无竞争:线程之间不存在竞争直接获取资源就可以了
  2. 存在竞争:使用非锁方式实现同步线程。这就是我们耳熟能详的CAS(Compare And Swap)

CAS通过操作系统的一条指令来实现,所以它可以保证原子性,通过诸如CAS这种方式,我们就可以进行无锁编程。

偏向锁

​ 上面我们也分析了依赖操作系统的mutex lock导致性能低下的原因,所以在大部分情况下无锁的效率是很高的,但这并非意味着无锁能全面代替有锁。

​ 现在我们给对象开始加锁,假如一个对象被加锁了,但在实际运行时只有一个线程会获取这个对象锁。那么我们最理想的情况就是不通过线程状态切换,也不需要通过CAS来获得锁,因为这多多少少还是会耗费一些资源。我们设想的是最好对象能够认识这个线程,只要是这个线程过来,那么对象直接把锁交出去,我们就可以认为这个锁偏爱这个线程,所以被称为偏向锁,那么偏向锁是如何实现的?

再看到这幅Mark Word的图:

image-20210718170547704

我们先通过判断后三位来判断是否是偏向锁,如果是偏向锁的话,它的前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(同步方法)

JAVA synchronized关键字锁机制(中)

java 多线程9 : synchronized锁机制 之 代码块锁

Java锁机制总结

Java并发006使用层面:Lock锁机制全解析