JDK synchronized wait notify/notifyAll Lock Condition await signal/signalAll

博文目录


synchronized详解

synchronized 基础

在 JDK1.5 之前, synchronized 是一个重量级锁, 频繁加解锁操作相对来说比较影响性能. JDK 6 官⽅从 JVM 层⾯对 synchronized 进行了⼤优化, 大幅提升了其运行效率. 现在的 synchronized 可以说非常高效, 在较为简单的场景完全可以替代 Lock

所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。

Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock

不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。

synchronized 内置锁 是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。

// 经典单例 双重检查
public class Singleton {
    
    
	private Singleton() {
    
    }
	private static volatile Singleton instance;
	public static Singleton getInstance() {
    
    
		if (null == instance) {
    
    
			synchronized (Singleton.class) {
    
    
				if (null == instance) {
    
    
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
}

synchronized 的使用方式

synchronized 可以修饰静态方法, 也可以修饰实例方法, 也可以修饰代码块. 不管哪种方式, 都要明确 synchronized 的作用对象

不论使用 synchronized 的哪种方式, 只要有一个线程抢到了某一个对象的锁, 则争抢该对象锁的其他线程将阻塞等待(即使调用的是不同的方法), 直到该线程释放锁, 其他线程将被唤醒开启新一轮的争抢

举例: 不同线程同时运行下面的方法时, 线程彼此间会相互竞争锁, 因为他们都是对 SynchronizedTest.class 对象加锁

  • SynchronizedTest::foo, 修饰静态方法
  • SynchronizedTest::a, 修饰静态方法中的代码块
  • object1::e, 修饰动态方法中的代码块
  • object2::e, 修饰动态方法中的代码块
package jdk.java.util.concurrent.locks;

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.TimeUnit;

@Slf4j
public class SynchronizedTest {
    
    

	private static final Object lock1 = new Object();
	private static final Object lock2 = new Object();

	public static synchronized void foo() {
    
    
		// 修饰静态方法, SynchronizedTest.class
		sleep();
		log.info("SynchronizedTest.class, foo, static, method");
	}

	public synchronized void bar() {
    
    
		// 修饰动态方法, this, 就是调用 e 方法的那个实例对象
		sleep();
		log.info("this, bar, dynamic, method");
	}

	public static void a() {
    
    
		synchronized (SynchronizedTest.class) {
    
    
			// 修饰代码块, SynchronizedTest.class
			sleep();
			log.info("SynchronizedTest.class, a, static, code block");
		}
	}

	public static void b() {
    
    
		synchronized (lock1) {
    
    
			// 修饰代码块, lock1
			sleep();
			log.info("lock1, b, static, code block");
		}
	}

	public static void c() {
    
    
		synchronized (lock2) {
    
    
			// 修饰代码块, lock2
			sleep();
			log.info("lock2, c, static, code block");
		}
	}

	public void d() {
    
    
		synchronized (this) {
    
    
			// 修饰代码块, this, 就是调用 e 方法的那个实例对象
			sleep();
			log.info("this, d, dynamic, code block");
		}
	}

	public void e() {
    
    
		synchronized (SynchronizedTest.class) {
    
    
			// 修饰代码块, SynchronizedTest.class
			sleep();
			log.info("SynchronizedTest.class, e, dynamic, code block");
		}
	}

	public void f() {
    
    
		synchronized (lock1) {
    
    
			// 修饰代码块, lock1
			sleep();
			log.info("lock1, f, dynamic, code block");
		}
	}

	public void g() {
    
    
		synchronized (lock2) {
    
    
			// 修饰代码块, lock2
			sleep();
			log.info("lock2, g, dynamic, code block");
		}
	}
	
	private static void sleep() {
    
    
		try {
    
    
			TimeUnit.SECONDS.sleep(1);
		} catch (Throwable cause) {
    
    
			cause.printStackTrace();
		}
	}

	public static void main(String[] args) {
    
    

		SynchronizedTest object1 = new SynchronizedTest();
		SynchronizedTest object2 = new SynchronizedTest();

		try {
    
    

			// SynchronizedTest.class
//			new Thread(SynchronizedTest::foo, "SynchronizedTest::foo").start();
//			new Thread(SynchronizedTest::a, "SynchronizedTest::a").start();
//			new Thread(object1::e, "object1::e").start();
//			new Thread(object2::e, "object2::e").start();
			//[20220427.163648.034][INFO ][SynchronizedTest::foo] SynchronizedTest.class, foo, static, method
			//[20220427.163649.037][INFO ][object2::e           ] SynchronizedTest.class, e, dynamic, code block
			//[20220427.163650.040][INFO ][object1::e           ] SynchronizedTest.class, e, dynamic, code block
			//[20220427.163651.054][INFO ][SynchronizedTest::a  ] SynchronizedTest.class, a, static, code block

			// this (object1)
//			new Thread(object1::bar, "object1::bar").start();
//			new Thread(object1::d, "object1::d").start();
			//[20220427.163734.814][INFO ][object1::bar         ] this, bar, dynamic, method
			//[20220427.163735.826][INFO ][object1::d           ] this, d, dynamic, code block

			// this (object2)
//			new Thread(object2::bar, "object2::bar").start();
//			new Thread(object2::d, "object2::d").start();
			//[20220427.163751.118][INFO ][object2::bar         ] this, bar, dynamic, method
			//[20220427.163752.129][INFO ][object2::d           ] this, d, dynamic, code block

			// lock1
//			new Thread(SynchronizedTest::b, "SynchronizedTest::b").start();
//			new Thread(object1::f, "object1::f").start();
//			new Thread(object2::f, "object2::f").start();
			//[20220427.163811.036][INFO ][SynchronizedTest::b  ] lock1, b, static, code block
			//[20220427.163812.050][INFO ][object2::f           ] lock1, f, dynamic, code block
			//[20220427.163813.064][INFO ][object1::f           ] lock1, f, dynamic, code block

			// lock2
//			new Thread(SynchronizedTest::c, "SynchronizedTest::c").start();
//			new Thread(object1::g, "object1::g").start();
//			new Thread(object2::g, "object2::g").start();
			//[20220427.163835.506][INFO ][SynchronizedTest::c  ] lock2, c, static, code block
			//[20220427.163836.516][INFO ][object2::g           ] lock2, g, dynamic, code block
			//[20220427.163837.530][INFO ][object1::g           ] lock2, g, dynamic, code block

			// SynchronizedTest.class, object1, object2, lock1, lock2 混搭
			// SynchronizedTest.class
			new Thread(SynchronizedTest::foo, "SynchronizedTest::foo").start();
			new Thread(SynchronizedTest::a, "SynchronizedTest::a").start();
			new Thread(object1::e, "object1::e").start();
			new Thread(object2::e, "object2::e").start();
			// object1
			new Thread(object1::bar, "object1::bar").start();
			new Thread(object1::d, "object1::d").start();
			// object2
			new Thread(object2::bar, "object2::bar").start();
			new Thread(object2::d, "object2::d").start();
			// lock1
			new Thread(SynchronizedTest::b, "SynchronizedTest::b").start();
			new Thread(object1::f, "object1::f").start();
			new Thread(object2::f, "object2::f").start();
			// lock2
			new Thread(SynchronizedTest::c, "SynchronizedTest::c").start();
			new Thread(object1::g, "object1::g").start();
			new Thread(object2::g, "object2::g").start();
			//[20220427.163903.295][INFO ][SynchronizedTest::b  ] lock1, b, static, code block
			//[20220427.163903.295][INFO ][SynchronizedTest::foo] SynchronizedTest.class, foo, static, method
			//[20220427.163903.295][INFO ][SynchronizedTest::c  ] lock2, c, static, code block
			//[20220427.163903.295][INFO ][object1::bar         ] this, bar, dynamic, method
			//[20220427.163903.295][INFO ][object2::bar         ] this, bar, dynamic, method
			//[20220427.163904.305][INFO ][object2::g           ] lock2, g, dynamic, code block
			//[20220427.163904.305][INFO ][object2::f           ] lock1, f, dynamic, code block
			//[20220427.163904.305][INFO ][object2::d           ] this, d, dynamic, code block
			//[20220427.163904.305][INFO ][object1::d           ] this, d, dynamic, code block
			//[20220427.163904.305][INFO ][object2::e           ] SynchronizedTest.class, e, dynamic, code block
			//[20220427.163905.313][INFO ][object1::f           ] lock1, f, dynamic, code block
			//[20220427.163905.313][INFO ][object1::g           ] lock2, g, dynamic, code block
			//[20220427.163905.313][INFO ][object1::e           ] SynchronizedTest.class, e, dynamic, code block
			//[20220427.163906.316][INFO ][SynchronizedTest::a  ] SynchronizedTest.class, a, static, code block

			TimeUnit.SECONDS.sleep(5);
			System.out.println();

		} catch (Throwable cause) {
    
    
			cause.printStackTrace();
		}

	}

}

synchronized 原理

synchronized 基于JVM内置锁,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、偏向锁(Biased Locking)、轻量级锁(Lightweight Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平

同步代码块

synchronized 关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令(javap -csvl Demo.class), 分别在同步块逻辑代码的起始位置与结束位置

同步方法

从编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:

当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致 “用户态和内核态” 两个态之间来回切换,对性能有一定影响。

Monitor 管程

管程是一个抽象的概念模型,其封装了一套对共享资源访问的模型,目的是通过一个模型来管理共享资源的访问过程,让可能存在多个进程或线程同时访问一个共享资源时能达到"互斥"和"同步"的效果,管程实现管程模型必须达到下面两点要求

  • 管程中的共享变量对于外部都是不可见的,只能通过管程才能访问对应的共享资源。
  • 管程是互斥的,某个时刻只能允许一个进程或线程访问共享资源。
  • 管程中需要有线程等待队列和相应等待和唤醒操作。
  • 必须有一种办法使进程无法继续运行时被阻塞。

我们来理解下上面几个条件:

第1点和第2点我们都能理解,只能通过管程访问共享资源,并且每次只能有一个线程获得管程的执行权,这两个要求理解起来很简单,其实就是为了让线程之间达到互斥的效果。 然后看第3点要求,管程中要有等待队列和响应的等待和唤醒操作,这个也好理解,等待队列和唤醒可以使线程之间达到同步有序的执行。 第4点是比较让人费解的,什么时候线程会无法继续运行呢?为什么要在这个时候提供线程可以进入阻塞的方法。

咱们看一个案例:

场景:假如我们正在开发一个互联网项目; 角色:项目参与人员有产品经理、开发人员、测试人员参与; 限制:只有一个办公室可以使用,一个办公室一次只能容纳一个角色进入。 节点: 每个角色负责对应的节点,产品经理产品文档、开发人员产出项目代码、测试人员测试代码质量、产品进行验收。 条件:开发人员必须有了产品文档之后再产出项目代码、测试人员在开发人员开发完毕了之后进入测试、产品人员在测试完毕了之后进行验收。

在这个场景里面,多个角色就是系统的多个线程,办公室是一个共享资源同一时刻只能有一个角色进入,这个场景里面就有一个阻塞场景,就是当一个开发人员抢到了办公室钥匙之后,进入到办公室,结果发现产品的需求都没有出来,这个时候开发人员是没有办法进行工作的,所以只能一直等,等到有产品文档之后继续下一步,但是这个时候产品是没办法进入办公室工作的,因为锁在开发人员手里,所以开发人员一直等不到需求文档,而产品经理一直进入不了办公室,导致死锁。

那么这里就需要有一种方式,当开发人员发现条件不成立的时候,此时开发人员可以主动的放弃办公室的锁,然后告诉办公室门口的产品经理,让产品经理先进办公室完成工作,开发人员自己则进入一个等待队列,当产品经理完成了工作之后,产品经理通知开发人员,然后自己放弃房间钥匙,等待需求验收再开始下一轮的工作。

最后以这种 条件阻塞 的方式让获得锁的线程可以主动让出锁,并等待其他线程唤醒再来检测条件,避免了某一个线程因为条件不满足导致任务无法进行,而因为别的线程无法进入到管程里,导致这个条件永远也无法改变锁造成的死锁问题。

下面这个图可能不太严谨, 但是有助于理解管程模型
在这里插入图片描述
通过上面的管程我们再来看JAVA里面的管程Monitor,JAVA是通过sychronyzed关键字,和wait()、notify、notifyAll() 方法实现了整个管程模型, 与上面标准的管程模型不同的是,JAVA的Monitor属于一种简单的管程模型,因为它并没有使用多个条件变量的队列,竞争锁是啊比的线程放到锁抢占队列,拿到锁却因为某个条件而主动阻塞的线程放到锁条件阻塞队列

Monitor 管程

Monitor(管程)是什么意思?Java中Monitor(管程)的介绍

管程,英文是 Monitor,也常被翻译为“监视器”,monitor 不管是翻译为“管程”还是“监视器”,都是比较晦涩的,通过翻译后的中文,并无法对 monitor 达到一个直观的描述。

在《操作系统同步原语》 这篇文章中,介绍了操作系统在面对 进程/线程 间同步的时候,所支持的一些同步原语,其中 semaphore 信号量 和 mutex 互斥量是最重要的同步原语。

在使用基本的 mutex 进行并发控制时,需要程序员非常小心地控制 mutex 的 down 和 up 操作,否则很容易引起死锁等问题。为了更容易地编写出正确的并发程序,所以在 mutex 和 semaphore 的基础上,提出了更高层次的同步原语 monitor,不过需要注意的是,操作系统本身并不支持 monitor 机制,实际上,monitor 是属于编程语言的范畴,当你想要使用 monitor 时,先了解一下语言本身是否支持 monitor 原语,例如 C 语言它就不支持 monitor,Java 语言支持 monitor。

一般的 monitor 实现模式是编程语言在语法上提供语法糖,而如何实现 monitor 机制,则属于编译器的工作,Java 就是这么干的。

monitor 的重要特点是,同一个时刻,只有一个 进程/线程 能进入 monitor 中定义的临界区,这使得 monitor 能够达到互斥的效果。但仅仅有互斥的作用是不够的,无法进入 monitor 临界区的 进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。显然,monitor 作为一个同步工具,也应该提供这样的管理 进程/线程 状态的机制。想想我们为什么觉得 semaphore 和 mutex 在编程上容易出错,因为我们需要去亲自操作变量以及对 进程/线程 进行阻塞和唤醒。monitor 这个机制之所以被称为“更高级的原语”,那么它就不可避免地需要对外屏蔽掉这些机制,并且在内部实现这些机制,使得使用 monitor 的人看到的是一个简洁易用的接口。

monitor 基本元素

monitor 机制需要几个元素来配合,分别是:

  • 临界区
  • monitor 对象及锁
  • 条件变量以及定义在 monitor 对象上的 wait,signal 操作。

使用 monitor 机制的目的主要是为了互斥进入临界区,为了做到能够阻塞无法进入临界区的 进程/线程,还需要一个 monitor object 来协助,这个 monitor object 内部会有相应的数据结构,例如列表,来保存被阻塞的线程;同时由于 monitor 机制本质上是基于 mutex 这种基本原语的,所以 monitor object 还必须维护一个基于 mutex 的锁。

此外,为了在适当的时候能够阻塞和唤醒 进程/线程,还需要引入一个条件变量,这个条件变量用来决定什么时候是“适当的时候”,这个条件可以来自程序代码的逻辑,也可以是在 monitor object 的内部,总而言之,程序员对条件变量的定义有很大的自主性。不过,由于 monitor object 内部采用了数据结构来保存被阻塞的队列,因此它也必须对外提供两个 API 来让线程进入阻塞状态以及之后被唤醒,分别是 wait 和 notify。

Java 语言对 monitor 的支持

monitor 是操作系统提出来的一种高级原语,但其具体的实现模式,不同的编程语言都有可能不一样。

临界区的圈定

在 Java 中,可以采用 synchronized 关键字来修饰实例方法、类方法以及代码块。被 synchronized 关键字修饰的方法、代码块,就是 monitor 机制的临界区。

monitor object

可以发现,synchronized 关键字在使用的时候,往往需要指定一个对象与之关联,这个对象就是 monitor object。

monitor 的机制中,monitor object 充当着维护 mutex 以及定义 wait/signal API 来管理线程的阻塞和唤醒的角色。

Java 语言中的 java.lang.Object 类,便是满足这个要求的对象,任何一个 Java 对象都可以作为 monitor 机制的 monitor object。

Java 对象存储在内存中,分别分为三个部分,即对象头、实例数据和对齐填充,而在其对象头中,保存了锁标识;同时,java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于一个叫 ObjectMonitor 模式的实现,这是 JVM 内部基于 C++ 实现的一套机制,基本原理如下所示:
在这里插入图片描述
当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 monitor 机制中称为条件变量。

ObjectMonitor (C++)
ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有该 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时

  1. 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
  2. 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;
  3. 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);

同时,Monitor对象地址存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。监视器Monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。

synchronized 使用的 monitor 监视器锁的锁状态被记录在每个对象的对象头(Mark Word)中

为什么 wait / notify 等方法必须在 synchronized 同步方法或同步代码块中使用

因为在Java的底层设计中,每一个Java对象都关联了一把看不见的锁,它就是Monitor锁 (C++中是 ObjectMonitor)。

当锁体升级为重量级锁时, jvm 依赖对象的 Monitor 锁和 monitorenter 和 monitorexit 指令来实现同步互斥

ObjectMonitor 对象中, owner / count / WaitSet / EntryList 等参数都有很重要的作用, owner 表示当前持有锁的线程, count 表示当前线程的进入次数, EntryList 是存放竞争当前锁的线程的同步队列, WaitSet 是存放竞争锁成功后因某些条件而又释放了锁的线程的等待队列

wait / notify 方法会操作当前 Monitor 对象中的这些属性, 进而影响到竞争锁的线程. 而只有在 monitorenter 和 monitorexit 之间, MonitorObject 各属性才会有对应的线程数据, monitor state 才是合法的, wait / notify 等方法才有意义

所以 jvm 强制要求 wait / notify 等必须在 synchronized 同步方法或同步代码块中使用, 否则报 IllegalMonitorStateException

synchronized 关键字

synchronized 关键字是 Java 在语法层面上,用来让开发者方便地进行多线程同步的重要工具。要进入一个 synchronized 方法修饰的方法或者代码块,会先获取与 synchronized 关键字绑定在一起的 Object 的对象锁,这个锁也限定了其它线程无法进入与这个锁相关的其它 synchronized 代码区域。

网上很多文章以及资料,在分析 synchronized 的原理时,基本上都会说 synchronized 是基于 monitor 机制实现的,但很少有文章说清楚,都是模糊带过。

参照前面提到的 Monitor 的几个基本元素,如果 synchronized 是基于 monitor 机制实现的,那么对应的元素分别是什么?

它必须要有临界区,这里的临界区我们可以认为是对对象头 mutex 的 P 或者 V 操作,这是个临界区

那 monitor object 对应哪个呢?mutex?总之无法找到真正的 monitor object。

所以我认为 “synchronized 是基于 monitor 机制实现的” 这样的说法是模棱两可的。

Java 提供的 monitor 机制,其实是 Object,synchronized 等元素合作形成的,甚至说外部的条件变量也是个组成部分。JVM 底层的 ObjectMonitor 只是用来辅助实现 monitor 机制的一种常用模式,但大多数文章把 ObjectMonitor 直接当成了 monitor 机制。

我觉得应该这么理解:Java 对 monitor 的支持,是以机制的粒度提供给开发者使用的,也就是说,开发者要结合使用 synchronized 关键字,以及 Object 的 wait / notify 等元素,才能说自己利用 monitor 的机制去解决了一个生产者消费者的问题。

MonitorEnter / MonitorExit

在这里插入图片描述

在 Java 虚拟机(HotSpot) 中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。

  • monitorenter:当monitor被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取monitor的所有权,过程如下:
    • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者
    • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1
    • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
  • monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
    monitorexit,指令出现了两次时,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

synchronized 的语义底层是通过一个monitor的对象来完成,其实 wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因

对象的内存布局

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;

在这里插入图片描述

对象头

Mark Word 详解

HotSpot虚拟机的对象头包括两部分信息

  • Mark Word: 存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容
  • Klass Pointer: 类型指针指向它的类元数据的指针

Mark Word, 用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。

对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。

但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。

32位虚拟机 MarkWord
在这里插入图片描述

64位虚拟机 MarkWord
在这里插入图片描述
在这里插入图片描述

  • 锁标志位(lock): 区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • biased_lock: 是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • 分代年龄(age): 表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • hashcode(hash): 运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
  • 偏向锁的线程ID(JavaThread): 偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
  • epoch: 偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record: 轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor: 重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的

手动设置-XX:+UseCompressedOops

哪些信息会被压缩?
1.对象的全局静态变量(即类属性)
2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节

在Scott oaks写的《java性能权威指南》第八章8.22节提到了当heap size堆内存大于32GB是用不了压缩指针的,对象引用会额外占用20%左右的堆空间,也就意味着要38GB的内存才相当于开启了指针压缩的32GB堆空间。

这是为什么呢?看下面引用中的红字(来自openjdk wiki:https://wiki.openjdk.java.net/display/HotSpot/CompressedOops)。32bit最大寻址空间是4GB,开启了压缩指针之后呢,一个地址寻址不再是1byte,而是8byte,因为不管是32bit的机器还是64bit的机器,java对象都是8byte对齐的,而类是java中的基本单位,对应的堆内存中都是一个一个的对象。
Compressed oops represent managed pointers (in many but not all places in the JVM) as 32-bit values which must be scaled by a factor of 8 and added to a 64-bit base address to find the object they refer to. This allows applications to address up to four billion objects (not bytes), or a heap size of up to about 32Gb. At the same time, data structure compactness is competitive with ILP32 mode.

对象头分析工具
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>
System.out.println(ClassLayout.parseInstance(object).toPrintable());
object为我们的锁对象

synchronized 优化

从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,随着锁竞争激烈程度的提升, 锁可以从无锁升级到偏向锁, 从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

锁的膨胀升级过程

浅谈偏向锁、轻量级锁、重量级锁

在这里插入图片描述

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁 (会涉及到一些CAS操作, 耗时) 的代价而引入偏向锁。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提高了程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

默认开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

下图中的线 程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程:
在这里插入图片描述

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

TODO 这里需要补充修改

Synchronized 与 Lock

Java里面的两种管程模型

Java里面有一种管程模型Monitor,synchronized就是基于Monitor实现的管程模型,在这个模型里面,synchronized中用锁解决了资源互斥问题,然后提供了wait(),notify(),notifyAll() 一组方法解决了线程同步问题,Java里面另一种管程模型就是Lock+Condition, 在此模型中 Lock是用来解决资源互斥的问题,而Condition里也提供wait(),signal(),signalAll() 同样也是解决线程同步问题,如果你理解了synchronized那么我相信你再来看Lock+Condition也会很简单。

我们用两种管程模型一对比会发现,他们的逻辑基本没什么区别,同样都是所用锁解决互斥问题,提供了一个阻塞队列,并同时提供了一组方法来对阻塞队列的线程进行操作,达到解决线程同步的效果。也许你会想既然有了synchronized为什么jdk还要提供Lock和Condition呢,而且synchronized傻瓜式的应用更方便简单,而Lock和Condition却麻烦多了,要自己加锁、释放锁,一不小心就出BUG了。

Synchronized 和 Lock 的区别

当然,既然存在就有其合理性,也许你会想到Lock的性能要比synchronized好,在JDK1.6版本之前Lock的性能的确要比synchronized要好,但是在JDK1.6起对 synchronized做了一系列的优化后,synchronized的整体性能提升上来了,所以性能并非Lock存在的原因,那么究竟是什么原因让JDK非得重复造轮子,答案就是Lock提供了更多解决死锁问题的方法:

  1. Lock提供了超时机制
    超时机制可以让我们更灵活的控制程序,而不必陷入等待锁的死循环中,在一定时间内获取不到锁,线程就释放出来继续干下面的事情,而synchronized一旦尝试加锁,就会死等,所以这种情况就有可能会出现死锁。

  2. Lock阻塞的线程可以响应中断
    synchronized线程一旦获取锁失败就会进行阻塞,而阻塞状态下的线程是无法响应中断(Interrupted)的,而Lock是支持中断响应的,一旦发现可能出现死锁,可立即中断某个线程。

  3. Lock支持非阻塞的获取锁
    Lock支持不阻塞的方式获取锁,以这种方式获取锁时会返回获取锁是否成功,当尝试获取锁不成功时,线程并不会阻塞。

  4. Lock+Condition可以支持多个条件
    synchronized只有一个等待队列,任何情况的阻塞都是放在一个队列里面的,Lock可以创建多个Condition队列,不同的Condition控制不同的条件,每个Condition有单独的一个队列。

猜你喜欢

转载自blog.csdn.net/mrathena/article/details/124449211