Synchronized 是 Java 中的关键字,是一种同步锁。在多线程编程中,有可能会出现多个线程同时争抢同一个共享资源的情况,这个资源一般被称为临界资源。这种共享资源可以被多个线程同时访问,且又可以同时被多个线程修改,然而线程的执行是需要 CPU 的资源调度,其过程是不可控的,所以需要采用一种同步机制来控制对共享资源的访问,于是线程同步锁—— Synchronized 就应运而生了。
Synchronized 用法Synchronized 关键字主要有以下 3 种使用方式:
修饰实例方法public synchronized void test1(){ }
Synchronized 锁定的是具体的一个实例对象,即该类的不同实例对象之间的锁是隔离的,当多个线程操作的实例对象不一样的,可以同时访问相同的被 Synchronized 修饰的方法。
修饰静态方法public synchronized static void test2(){ }
Synchronized 锁定的是整个 class 对象,即不同线程操作该类的不同实例对象时,只要被 Synchronized 修饰的代码都无法同步访问。
修饰代码块public void test3(){ synchronized(new test()){ } }
指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
其实就是锁方法、锁代码块和锁对象,那他们是怎么实现加锁的呢?
在了解 Synchronized 的实现原理之前,我们需要先对对象的内存布局有个基本了解。在 JVM 中,对象在内存中分为三块区域,对象头,实例数据和对齐填充
对象头
Mark Word(标记字段):默认存储对象自身运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳、对象分代年龄,这部分信息称为 “Mark Word”;Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据自己的状态复用自己的存储空间Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例 实例数据
存放类的属性数据信息,包括父类的属性信息 对齐填充
由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
下面我们看一下对像头的结构:
对象头中与锁有关的就是这个 Mark Word,Mark Word 在不同的虚拟机中所占的空间不同,32 位 JVM 中占 32 bit,64 位 JVM 中占 64 bit。
在 32 位虚拟机下,Mark Word 是 32bit 大小的,其存储结构如下:
在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如下:
现在虚拟机基本是 64 位的,而 64 位的对象头有点浪费空间,JVM 默认会开启指针压缩,所以基本上也是按 32 位的形式记录对象头的。也可以通过下面参数进行控制 JVM 开启和关闭指针压缩:
开启压缩指针:(-XX:+UseCompressedOops)关闭压缩指针:(-XX:-UseCompressedOops)
那为什么 JVM 需要默认开启指针压缩呢?
这是因为在对象头上类元信息指针 Klass pointer 在 32 位 JVM 虚拟机中用 4 个字节存储,但是到了 64 位 JVM 虚拟机中 Klass pointer 用的确是 8 个字节来存储,一些对象在 32 位虚拟机用的也是 4 字节来存储,到了 64 位机器用的都是 8 字节来存储了。
一个项目中有成千上万的对象,如果每个对象都用 8 字节来存放的话,那这些对象无形中就会增加很多空间,导致堆的压力就会很大,堆很容易就会满了,然后就会更容易的触发 GC,那指针压缩的最主要的作用就是压缩每个对象内存地址的大小,那么同样堆内存大小就可以放更多的对象。
Synchronized 的实现原理 同步代码当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们来看一段代码:
public class SynchronizedTest {//同步代码块public void test(){synchronized (this){System.out.println("哈哈哈");}}}
这里我们把这个 .java 编译为 .class 字节码文件,通过 JDK 工具命令来进行反编译:javap -v SynchronizedTest.class。
反编译结果:
根据上述字节码可以看出当 Synchronized 修饰代码块时是通过 monitorenter 和 monitorexit 指令来实现的。
monitorenter: 每个对象都是一个监视器锁(monitor),当且有一个 monitor 被持有后,它将处于锁定的状态,其他线程无法来获取该 monitor。当 JVM 执行某个线程的某个方法内部的 monitorenter 时,他会尝试去获取当前对应的 monitor 的所有权。其过程如下:
如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权 monitorexit: 执行 monitorexit 指令的线程一定是拥有当前对象的 monitor 的所有权的线程,执行 monitorexit 时会将 monitor 的进入数减 1。当 monitor 的进入数减为 0 时,当前线程退出 monitor,不再拥有 monitor 的所有权,此时其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权。
还有就是从上面的截图中,我们发现 monitorexit 指令出现了 2 次,第 1 次为同步正常退出释放锁,第 2 次为发生异步退出释放锁。
通过上面的描述,我们可以知道 Synchronized 的底层是通过一个 monitor 的对象来完成,wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 异常的原因。
下面我们再来看一下同步方法
同步方法public class SynchronizedTest {public synchronized void test() {System.out.println("哈哈哈");}}
这里也通过 JDK 工具命令来进行反编译:javap -v SynchronizedTest.class。
反编译结果:
从字节码反编译的可以看出,同步方法并没有通过指令 monitorenter 和 monitorexit 来实现的,虽然没有通过这两个指令来实现,但是有没有发现多了个 ACC_SYNCHRonIZED 的标识符?而 JVM 就是根据该标示符来实现方法同步的。
它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRonIZED 访问标志区分一个方法是否同步方法。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRonIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor, 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放 monitor。
在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个 monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放。
所以归根究底,还是 monitor 对象的争夺。
什么是 monitor?monitor通常被描述为一个对象,可以把它理解为 一个同步工具,也可以描述为 一种同步机制,所有的 Java 对象自从 new 出来的时候就自带了一把锁,那就是 monitor 锁,也就是对象锁。
在Java虚拟机(HotSpot)中,monitor 监视器源码是 C++ 写的,在虚拟机的 ObjectMonitor.hpp 文件中。
从源码中,看到它的数据结构如下所示:
ObjectMonitor() { _header = NULL; _count = 0; //记录个数 _waiters = 0, _recursions = 0; //线程的重入次数 _object = NULL; 存储该 monitor 的对象 _object = NULL; //存储 monitor 对象 _owner = NULL; //持有当前线程的owner _WaitSet = NULL; //wait 状态的线程列表,处于 wait 状态的线程,会被加入到 _WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; //多线程竞争锁时的单向列表 FreeNext = NULL ; _EntryList = NULL ; //处于等待锁 block 状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; _previous_owner_tid = 0; }
_owner:初始时为 NULL。当有线程占有该 monitor 时,owner 标记为该线程的唯一标识。当线程释放 monitor 时,owner 又恢复为 NULL。owner 是一个临界资源,JVM 是通过 CAS 操作来保证其线程安全的_cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)_EntryList:_cxq 队列中有资格成为候选资源的线程会被移动到该队列中 _WaitSet:因为调用 wait 方法而被阻塞的线程会被放在该队列中
当多个线程同时访问一段同步代码时:
首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后,进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 coun t加 1若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒若当前线程执行完毕,也将释放 monitor(锁)并复位 count 的值,以便其他线程进入获取 monitor(锁)
ObjectMonitor 的数据结构中包含:_owner、_WaitSet 和 _EntryList,它们之间的关系转换可以用下图:
对象内置锁 ObjectMonitor 流程如下:
所有想要获得锁的线程,在锁已经被其它线程拥有的时候,这些想要获得锁的线程就进入了对象锁的 _EntryList 区域所有以前获得过锁,但是由于其它必要条件不满足而需要 wait 的时候,线程就进入了对象锁的 _WaitSet 区域 在 _WaitSet 区域的线程获得 Notify/notifyAll 通知时,随机的一个 Thread(Notify)或者是全部的 Thread(NotifyALL)从对象锁的 _WaitSet 区域进入了 _EntryList 中在当前拥有锁的线程释放掉锁的时候,处于该对象锁的 _EntryList 区域的线程都会抢占该锁,但是只能有任意的一个 Thread 能取得该锁,而其他线程依然在 _EntryList 中等待下次来抢占到锁之后再执行 monitor 重量级锁
大家在看 ObjectMonitor 源码的时候,会发现 Atomic::cmpxchg_ptr,Atomic::inc_ptr 等内核函数,对应的线程就是 park() 和 upark()。
执行同步代码块,没有竞争到锁的对象会 park() 被挂起,竞争到锁的线程会 unpark() 唤醒。这个时候就会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。所以 synchronized 是 Java 语言中是一个重量级操作。
用户态和和内核态又是什么呢?要想了解用户态和内核态还需要先了解一下 Linux 系统的体系架构:
从上图可以看出,Linux 操作系统的体系架构分为:用户空间和内核。
内核:本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境用户空间:上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等
系统调用:为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口。
所有进程初始都运行于用户空间,此时即为用户运行状态(简称:用户态),但是当它调用系统调用执行某些操作时,例如 I/O 调用,此时需要陷入内核中运行,我们称进程处于内核运行态(或简称为内核态)。 系统调用的过程可以简单理解为:
用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供的服务用户态执行系统调用(系统调用是操作系统的最小功能单位)CPU切换到内核态,跳到对应的内存指定的位置执行指令系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令
所以为什么说 1.6 之前是重量级锁,没错,但是他重量的本质是 ObjectMonitor 调用的过程,以及 Linux 内核的复杂运行机制决定的,大量的系统资源消耗,所以效率才低。
不过在 jdk 1.6 中对 Synchronized 的实现进行了各种优化,使得它显得不是那么的重了,那么 JVM 对它的优化采用了哪些优化的手段呢?
优化锁升级 锁的类型jdk 1.6 对锁的实现引入了大量的优化,比如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁以及轻量级锁这些技术来减少锁操作的开销。
jdk 1.6 里锁一共有 4 种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。
升级方向:
这里需要注意的是:升级过程是不可逆的
自旋锁线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁,那么何谓自旋锁?
自旋锁就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋等待的时间必须要有一个限度,如果自旋超过了定义的时间还没有获取到锁,应该被挂起。
自旋锁在 jdk 1.4 中引入,默认关闭,可以使用 -XX:+UseSpinning 开启,而在 jdk 1.6 中默认开启,自旋默认的次数为 10 次,通过参数 -XX:PreBlockSpin 来调整。
如果通过参数 -XX:preBlockSpin 来调整自旋锁的自旋次数,会很不方便。如果我将参数调整为 10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),那么会不会很尴尬呢?于是在 jdk1.6 引入自适应的自旋锁。
适应性自旋锁jdk 1.6 引入了自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
那么它是怎么做的呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
锁消除锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去,从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁就无须进行。变量是否逃逸,对应虚拟机来说需要使用数据分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?
我们虽然没有显示使用锁,但是我们在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐形的加锁操作。
public String stringBuffers(String s1, String s2, String s3){ StringBuffer stringBuffer = new StringBuffer(); stringBuffer .append(s1); stringBuffer .append(s2); stringBuffer .append(s3); return stringBuffer .toString(); }
我们进去 StringBuffer 的 append 方法源码如下:
@Override public synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this; }
也就是说在 stringBuffers() 方法中涉及了同步操作。但是可以观察到 stringBuffer 对象它的作用域被限制在方法的内部,也就是 stringBuffer 对象不会逃逸出去,其他线程无法访问。因此,虽然这里有锁,但是可以被安全的消除,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
锁粗化我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能消耗。
public class Test {public static void main(String[] args) throws InterruptedException {StringBuffer str = new StringBuffer();str.append("a");str.append("b");str.append("c");System.out.println("a");System.out.println("b");System.out.println("c");}}
如果虚拟机检测到有这样一串零碎的操作都使用一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只需要加锁一次就可以了。
偏向锁偏向锁是 jdk1.6 中的重要引进,因为 HotSpot 作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
上面说过对象头是由 Mark Word 和 Klass pointer 组成,锁争夺也就是对象头指向的 Monitor 对象的争夺,一旦有线程持有了这个对象,标志位修改为 1,就进入偏向模式,同时会把这个线程的 ID 记录在对象的 Mark Word 中。
这个过程是采用了 CAS 乐观锁操作的,每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位 +1 就好了,不同线程过来,CAS 会失败,也就意味着获取锁失败。
偏向锁在1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是xx:-UseBiasedLocking=false。
偏向锁关闭,或者多个线程竞争偏向锁怎么办呢?
轻量级锁这里还是跟 Mark Work 相关,如果同步对象锁状态为无锁状态(锁标志位为 01 状态,是否为偏向锁为 0 ),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用来存储锁对象的 Mark Word 拷贝,然后把 Lock Record 中的 owner 指向当前对象。
虚拟机接下来会利用 CAS 尝试把对象原本的 Mark Word 更新为指向当前线程 Lock Record 的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。
如果失败了,就会判断当前对象的 Mark Word 是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。
如果这个更新操作失败了,虚拟机首先会检查对象 Mark Word 中的 Lock Word 是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行,若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为 10,Mark Word 中存储的就是指向重量级锁的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
重量级锁Synchronized 是通过对象内部的一个叫做监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。
而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。
因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 重量级锁。
锁的优劣各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。
如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连 CAS 都不用做,仅仅在内存中比较下对象头就可以了如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁如果其他线程通过一定次数的 CAS 尝试没有成功,则进入重量级锁 偏向锁、轻量级锁以及重量级锁的比较