synchronized 这个关键字的重要性不言而喻,几乎可以说是并发、多线程必须会问到的关键字了。synchronized 会涉及到锁、升级降级操作、锁的撤销、对象头等。所以理解 synchronized 非常重要,本篇文章就带你从 synchronized 的基本用法、再到 synchronized 的深入理解,对象头等,为你揭开 synchronized 的面纱。
浅析 synchronized
synchronized
是 Java 并发模块 非常重要的关键字,它是 Java 内建的一种同步机制,代表了某种内在锁定的概念,当一个线程对某个共享资源
加锁后,其他想要获取共享资源的线程必须进行等待,synchronized 也具有互斥和排他的语义。
什么是互斥?我们想必小时候都玩儿过磁铁,磁铁会有正负极的概念,同性相斥异性相吸,相斥相当于就是一种互斥的概念,也就是两者互不相容。
synchronized 也是一种独占的关键字,但是它这种独占的语义更多的是为了增加线程安全性,通过独占某个资源以达到互斥、排他的目的。
在了解了排他和互斥的语义后,我们先来看一下 synchronized 的用法,先来了解用法,再来了解底层实现。
synchronized 的使用
关于 synchronized 想必你应该都大致了解过
- synchronized 修饰实例方法,相当于是对类的实例进行加锁,进入同步代码前需要获得当前实例的锁
- synchronized 修饰静态方法,相当于是对类对象进行加锁
- synchronized 修饰代码块,相当于是给对象进行加锁,在进入代码块前需要先获得对象的锁
下面我们针对每个用法进行解释
synchronized 修饰实例方法
synchronized 修饰实例方法,实例方法是属于类的实例。synchronized 修饰的实例方法相当于是对象锁。下面是一个 synchronized 修饰实例方法的例子。
public synchronized void method() { // ... }
像如上述 synchronized 修饰的方法就是实例方法,下面我们通过一个完整的例子来认识一下 synchronized 修饰实例方法
public class TSynchronized implements Runnable{ static int i = 0; public synchronized void increase(){ i++; System.out.println(Thread.currentThread().getName()); } @Override public void run() { for(int i = 0;i < 1000;i++) { increase(); } } public static void main(String[] args) throws InterruptedException { TSynchronized tSynchronized = new TSynchronized(); Thread aThread = new Thread(tSynchronized); Thread bThread = new Thread(tSynchronized); aThread.start(); bThread.start(); aThread.join(); bThread.join(); System.out.println("i = " + i); } }
上面输出的结果 i = 2000 ,并且每次都会打印当前现成的名字
来解释一下上面代码,代码中的 i 是一个静态变量,静态变量也是全局变量,静态变量存储在方法区中。increase 方法由 synchronized 关键字修饰,但是没有使用 static 关键字修饰,表示 increase 方法是一个实例方法,每次创建一个 TSynchronized 类的同时都会创建一个 increase 方法,increase 方法中只是打印出来了当前访问的线程名称。Synchronized 类实现了 Runnable 接口,重写了 run 方法,run 方法里面就是一个 0 - 1000 的计数器,这个没什么好说的。在 main 方法中,new 出了两个线程,分别是 aThread 和 bThread,Thread.join 表示等待这个线程处理结束。这段代码主要的作用就是判断 synchronized 修饰的方法能够具有独占性。
synchronized 修饰静态方法
synchronized 修饰静态方法就是 synchronized 和 static 关键字一起使用
public static synchronized void increase(){}
当 synchronized 作用于静态方法时,表示的就是当前类的锁,因为静态方法是属于类的,它不属于任何一个实例成员,因此可以通过 class 对象控制并发访问。
这里需要注意一点,因为 synchronized 修饰的实例方法是属于实例对象,而 synchronized 修饰的静态方法是属于类对象,所以调用 synchronized 的实例方法并不会阻止访问 synchronized 的静态方法。
synchronized 修饰代码块
synchronized 除了修饰实例方法和静态方法外,synchronized 还可用于修饰代码块,代码块可以嵌套在方法体的内部使用。
public void run() { synchronized(obj){ for(int j = 0;j < 1000;j++){ i++; } } }
上面代码中将 obj 作为锁对象对其加锁,每次当线程进入 synchronized 修饰的代码块时就会要求当前线程持有obj 实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待。
synchronized 修饰的代码块,除了可以锁定对象之外,也可以对当前实例对象锁、class 对象锁进行锁定
// 实例对象锁 synchronized(this){ for(int j = 0;j < 1000;j++){ i++; } } //class对象锁 synchronized(TSynchronized.class){ for(int j = 0;j < 1000;j++){ i++; } }
synchronized 底层原理
在简单介绍完 synchronized 之后,我们就来聊一下 synchronized 的底层原理了。
我们或许都有所了解(下文会细致分析),synchronized 的代码块是由一组 monitorenter/monitorexit 指令实现的。而Monitor
对象是实现同步的基本单元。
啥是
Monitor
对象呢?
Monitor 对象
任何对象都关联了一个管程,管程就是控制对象并发访问的一种机制。管程
是一种同步原语,在 Java 中指的就是 synchronized,可以理解为 synchronized 就是 Java 中对管程的实现。
管程提供了一种排他访问机制,这种机制也就是 互斥
。互斥保证了在每个时间点上,最多只有一个线程会执行同步方法。
所以你理解了 Monitor 对象其实就是使用管程控制同步访问的一种对象。
对象内存布局
在 hotspot
虚拟机中,对象在内存中的布局分为三块区域:
对象头(Header)
实例数据(Instance Data)
对齐填充(Padding)
这三块区域的内存分布如下图所示
我们来详细介绍一下上面对象中的内容。
对象头 Header
对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。
在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。
如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。
在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。
用中文翻译过来就是
- 无状态也就是
无锁
的时候,对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为 01。 偏向锁
中划分更细,还是开辟 25 bit 的空间,其中 23 bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01。轻量级锁
中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00。重量级锁
中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11GC标记
开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。
其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK
中的markOop.hpp类中的枚举窥出端倪
来解释一下
- age_bits 就是我们说的分代回收的标识,占用4字节
- lock_bits 是锁的标志位,占用2个字节
- biased_lock_bits 是是否偏向锁的标识,占用1个字节。
- max_hash_bits 是针对无锁计算的 hashcode 占用字节数量,如果是 32 位虚拟机,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虚拟机,64 - 4 - 2 - 1 = 57 byte,但是会有 25 字节未使用,所以 64 位的 hashcode 占用 31 byte。
- hash_bits 是针对 64 位虚拟机来说,如果最大字节数大于 31,则取 31,否则取真实的字节数
- cms_bits 我觉得应该是不是 64 位虚拟机就占用 0 byte,是 64 位就占用 1byte
- epoch_bits 就是 epoch 所占用的字节大小,2 字节。
在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态),偏向锁,轻量级锁,重量级锁,其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。
所以我们的重点放在对 synchronized 重量级锁的研究上,当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor
实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)
这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter
对象。
_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。
那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。