JMM之synchronized关键字
在JDK中实现线程同步等待闭环(FutureTask/Future)中实现同步等待,使用了synchronized关键字而且一定要使用该关键字。为什么呢?与其与生俱来的功能相关——对于通讯,涉及两个关键字volatile和synchronized:
Java支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝(虽然对象及其成员变量分配的内存在共享内存中,但是每个执行的线程还是可以拥有一份拷贝,这样做的目的是加速程序的执行),所以程序在执行过程中,一个线程看到的变量并不一定是最新的(即脏数据——JDK中实现线程同步等待闭环(FutureTask/Future)中多线程无法实现同步的原因)。
关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需从共享内存中获取,而对他的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一时刻,只能有一个线程出于方法或者同步块中,他保证了线程对变量访问的可见性和排他性。
本文的重点在synchronized关键字。利用synchronized,Java提供了强制原子性的内置锁机制:synchronized块。一个synchronized块分为两部分:锁对象的引用以及这个锁保护的代码块。synchronized方法是对跨越了这个方法体的synchronized块的简短描述,至于synchronized方法的锁就是该方法所在的对象本身(静态的synchronized方法从Class对象上获取锁)。
等价于:
对于Java锁分类定义的基本概念在Java锁概念的分类总结 - 池塘里洗澡的鸭子 - 博客园 (cnblogs.com)有介绍,那synchronized是什么锁呢?它是同步、重量级锁。synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。
每个Java对象都可以隐式地扮演一个用于同步锁的角色;这些内置的锁被称作内置锁(intrinsic locks)或监视器锁(monitor locks)。执行线程进入synchronized块之前会自动获得锁;而无论通过正常控制路径退出,还是从块中超出异常,线程都在放弃对synchronized块的控制时自动释放锁。获取内置锁的唯一途径是:进入这个内部锁保护的同步块或方法。
内部锁在Java中扮演了互斥锁(mutual exclusion lock,也称为mutex)的角色,意味着至多只有一个线程可以拥有锁,当线程A尝试请求一个被线程B占有的锁时,线程A必须等待或者阻塞直到B释放这个锁。如果B永远不释放锁,A将永远等下去。
从字节码的维度看synchronized的实现:
从class信息中,对于同步块的实现使用了monitorenter和moniterexit指令,而同时同步方法m则依靠方法修饰上的ACC_SYNCHRONIZED来完成(上图红框标记)。无论那种方式其本质都是对一个对象的监视器monitor进行获取,而这个获取过程得排他——即在给定时刻只能有一个线程获取到synchronized所保护对象的监视器。
任意一个对象都拥有自己的监视器,当这个对象由同步区域的同步方法调用时,执行该方法的线程必须现获得该对象监视器才能进入同步区域,同时没有获得监视器的线程会被阻塞在不同区域的入口处,进入BLOCKED状态。
任意线程对Object(Object由synchronized保护)的访问,首先要获得Object的监视器。如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当访问Object的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
同一时间内,只能有一个线程可以运行特定保护的代码块,因此,由同一锁保护的synchronized快会各自原子地执行不会相互干扰。在并发的上下文中,原子性的含义与他在事务性应用中相同——一组语句作为单独的不可分割的单元运行。执行synchronized块的线程不可能出现有其他线程能同时执行由同一个锁保护的synchronized块。
至于synchronized关键字涉及轻量级阻塞与重量级阻塞的概念:能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED(可参考线程基本方法及其对线程状态的影响 - 池塘里洗澡的鸭子 - 博客园 (cnblogs.com))。
那么synchronized用的锁的本质是什么呢?
synchronized 它可以把任意一个非 NULL 的对象当作锁。 他属于独占式的悲观锁,同时属于可重入锁。
锁是一个“对象”,作用如下:
1. 这个对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。最简单的情况是这个state有0、1两个取值,0表示没有线程占用这个锁,1表示有某个线程占用了这个锁。
2. 如果这个对象被某个线程占用,记录这个线程的thread ID。
3. 这个对象维护一个thread id list,记录其他所有阻塞的、等待获取拿这个锁的线程。在当前线程释放锁之后从这个thread id list里面取一个线程唤醒。
要访问的共享资源本身也是一个对象,例如前面的对象myClass,这两个对象可以合成一个对象。代码就变成synchronized(this) {…},要访问的共享资源是对象a,锁加在对象a上。当然,也可以另外新建一个对象,代码变成synchronized(obj1) {…}。这个时候,访问的共享资源是对象a,而锁加在新建的对象obj1上。资源和锁合二为一,使得在Java里面,synchronized关键字可以加在任何对象的成员上面。这意味着,这个对象既是共享资源,同时也具备“锁”的功能。
其实体保存在Java对象头里:
在对象头里,有一块数据叫Mark Word。在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。因为不同版本的JVM实现,对象头的数据结构会有各种差异。
总结synchronized关键字,如下:
1、Synchronized 作用范围
1) 作用于方法时,锁住的是对象的实例(this);
2)当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGenjdk1.8 则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
3)synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。 它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
2、Synchronized 核心组件
1) Wait Set:哪些调用 wait 方法被阻塞的线程被放置在这里;
2) Contention List: 竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
3) Entry List: Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
4)OnDeck:任意时刻, 最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
5)Owner:当前已经获取到所资源的线程被称为 Owner;
6)!Owner:当前释放锁的线程。
3、Synchronized 实现
1) JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争, JVM 会将一部分线程移动到 EntryList 中作为候选竞争线程。
2)Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程)。
3)Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck,OnDeck 需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM 中,也把这种选择行为称之为“竞争切换”。
4)OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList中。如果 Owner 线程被 wait 方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 notify或者 notifyAll 唤醒,会重新进去 EntryList 中。
5)处于 ContentionList、 EntryList、 WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的)。
6) Synchronized 是非公平锁。 Synchronized 在线程进入 ContentionList 时, 等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。
7) 每个对象都有个 monitor 对象, 加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的。
8) synchronized 是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。