并发编程的艺术-Synchronized的使用及原理
简介
线程可以给我们带来性能上的提升,但是也会给我们带来一系列不可控问题,而这些不可控问题中,最常见的就是线程的安全问题。synchronized
就是解决线程安全问题的方法之一。
synchronized
是一把同步锁,具有互斥性,加入synchronized
关键字后,在同一时间内,有且只有一个线程可以去调度某个方法。
使用
由于synchronized
的存在,会使原本的多线程异步操作,又变回了同步操作,势必又会影响许多性能问题,所以在具体使用过程当中,我们只需要去保护可能存在线程安全的方法即可。synchronized
的使用有两种,分别是
- 类锁
- 对象锁
- 代码块锁
在了解这两把锁之前,我们先看一个存在线程安全问题的例子
public class StaticSynchronized {
public static int i = 0;
public static void increment() {
i++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[2];
for (int i = 0; i < 2; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100000; j++) {
StaticSynchronized.increment();
}
});
threads[i].start();
}
threads[0].join();
threads[1].join();
System.out.println(StaticSynchronized.i);
}
}
上述代码的创建了两个线程,由两个线程同时调度StaticSynchronized
类的递增方法,我们对于这段代码实际预期结果为200000
,但是实际呢?来看看执行三次的结果
执行结果为:130222
执行结果为:105066
执行结果为:104079
可以发现预期结果远远不到预期值,这个时候就需要我们的主角synchronized
。
类锁
类锁是一把全局锁,多个线程调用多个实例对象的synchronized
方法时,会产生互斥,类锁的使用有两种方式
-
方式一: 修饰静态方法
将
StaticSynchronized
类中的increment()
静态方法加上synchronized
的关键字
public synchronized static void increment() {
i++;
}
执行结果如下:
执行结果为:200000
-
方式二: 修饰代码块
我们结合定义中的多实例互斥来举例:
- 首先创建一把锁
public class Lock {}
- 创建两个待线程调度的类
public class ClassSynchronizedOne { public void test(){ synchronized (Lock.class) { for (int i = 0; i < 100; i++) { System.out.println("当前线程" + Thread.currentThread().getName()); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } } }
public class ClassSynchronizedTwo { public void test(){ synchronized (Lock.class) { for (int i = 0; i < 100; i++) { System.out.println("当前线程" + Thread.currentThread().getName()); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } } }
- 启动两个线程,并同时启动
public static void main(String[] args) { ClassSynchronizedTwo two = new ClassSynchronizedTwo(); ClassSynchronizedOne one = new ClassSynchronizedOne(); new Thread(() -> one.test(),"这是one线程").start(); new Thread(() -> two.test(),"这是two线程").start(); }
- 打印结果如下:
当前线程这是one线程 当前线程这是one线程 当前线程这是one线程 当前线程这是one线程 当前线程这是one线程 当前线程这是one线程 当前线程这是one线程 当前线程这是one线程
根据结果你会发现,只有一个线程在执行,哪个线程抢到了锁,哪个线程执行。
对象锁
对象锁则是多个线程调用同一个实例对象的同步方法时才会产生互斥,它也有两种方式
- 方式一: 修饰普通方法
public synchronized void increment() {
i++;
}
- 方式二: 修饰代码块,我们同样以一个多线程调用的示例来体现,我们对上述代代码中的
Lock.class
类锁的方式改为对象锁
public void test(){
Lock lock = new Lock();
synchronized (lock) {
for (int i = 0; i < 100; i++) {
System.out.println("当前线程" + Thread.currentThread().getName());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
执行结果如下:
当前线程这是one线程
当前线程这是two线程
当前线程这是one线程
当前线程这是two线程
当前线程这是two线程
当前线程这是one线程
当前线程这是two线程
你会发现这两个线程没有相互干扰,各执行各的。
综上两种锁形式,我们可以做一个总结
- 对于静态方法加锁,锁为当前类的Class对象 (类锁方法一)
- 对于普通方法加锁,锁为当前实例的对象 (对象锁方法一)
- 对于同步方法块,锁由当前Synchronized括号里配置决定,Class则为类锁,实例对象则为对象锁 (类锁对象锁的方法二)
Mark Word锁标记
在使用过程当中,我们会发现,synchronized
想要实现多线程访问的互斥性,势必要满足以下两个条件:
- 有一个共享资源作为竞争的对象
- 这个竞争的对象势必有一个标记来决定是不是在锁的状态
那么,这个标记在哪呢?他又存储了什么信息呢?
首先,Java的存储结构可以分为三个部分:
- 对象头 : 见下文
- 实例数据 : 包含对象的所有成员变量
- 对齐填充: 保证对象的大小是8字节的整数倍
而对象头,同样也由三部分组成:
- Mark Word: 存储锁标记,分代年龄,hashCode等信息
- Klass Pointer: 指向方法区的Class信息的指针,可以理解为当前对象是哪个Class的实例
- Array Length: 表示数组的长度,只有当前对象是数组的时候才会有此属性
大家看到没,锁标记的存储位置,就是Mark Word,那么Mark Word在不同锁状态下是如何变化的呢,我们来看图例:
由于32位跟64位的不同所以我们分开来看,先看32位的。
再来看64位的
从上图中,我们可以知道,锁一共有五种状态,分别是无锁、偏向锁、轻量级锁、重量级锁、GC标记,在MarkWord当中,他们用2bit来进行存储,但是2bit只能由00、01、10、11四种数据来表示,所以才会有1bit的偏向锁标记
这时候,又懵逼了,锁就是锁,怎么还有偏向锁、轻量级锁、重量级锁,其实,这涉及到了一个锁升级的过程,这个过程一共有四个级别,从低到高依次为无锁、偏向锁、轻量级锁、重量级锁。这个升级的过程,为了确保锁竞争的操作为原子性操作(所谓原子性,跟数据库的原子性概念差不多,要不然全部成功,要不全部失败),底层通过CAS来完成。为了更好的讲解锁升级的过程以及每种锁的原理,我们首先了解一下什么是CAS。
CAS的原理
CAS,全称CompareAndSwap,也有叫做CompareAndSet, 它是一个能够比较和替换的方法,在java源码中,它们是一个native
方法,这些方法能够在多线程环境下保证对共享变量修改时的原子性。由于CompareAndSwap
的一系列方法在JDK的Unsafe类中属于native方法,也就是说它们在JVM层面实现的,所以我们需要从Java以及JVM层面两种不同的环境进行分析,首先我们来看Java层面,这里使用
compareAndSwapInt`的处理机制举例,源码如下:
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
在代码中,
- o: 代表当前实例的对象
- offset: 表示目标变量在实例对象中内存地址的偏移量
- expect: 表示预期的值
- update: 表示需要更新的值
这么说可能有些抽象,我们看一个我们常用类的源码-AtomicInteger
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
......
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
......
}
从源码中我们可以分析出如下信息:
- 从静态代码块初始化,我们可以知道,
valueOffset
是AtomicInteger
中成员变量value
在内存当中的偏移量,这里就对应了compareAndSwapInt
中的第二个参数offset
- 源码当中的
this
,表示当前实例对象,对应着compareAndSwapInt
的第一个参数 v = getIntVolatile(o, offset)
,表示在当前实例的偏移值,获取当前实例的value
,所以如果在v
获取后没有其他线程去篡改value
的情况下while
循环时的v
应该等于value
compareAndSwapInt(o, offset, v, v + delta)
v
即为预期值, 如果v
等于内存中的value
即预期值与内存当中的值是一致的,v + delta
则更改为新的值v + 1
AtomicInteger
通过一个do…while的一个自我循环的方式不断地去尝试对value进行累加,这种行为,我们称之为自旋
锁升级的流程
当一个线程访问了使用了synchronized
修饰的代码时,就会触发加锁流程:
- 如果偏向锁在jvm层是开启状态,则尝试获取偏向锁来获得资源
- 如果当前已经有其他线程获取了偏向锁,此时锁就会升级为轻量级锁,如果轻量级锁依旧被占用,则膨胀为重量级锁。
- 尝试获取轻量级锁的线程开始自旋操作,如果多次自旋仍无法获得锁,则会升级为重量级锁进入线程等待。
流程图如下:
偏向锁
我们在书写多线程时,考虑到线程安全问题,故用到了synchronized
对代码进行加锁操作,但是当前代码可能并不存在多线程竞争关系,而且总是同一个线程获得这把锁。为了降低这种情况发生时获得锁的代价,所以引入了偏向锁的概念
偏向锁的获取
- 当一个线程访问用
synchronized
的修饰的同步代码块尝试获取锁时,会在Mark Word里校验当前的锁表示是否为偏向锁,如果不是,则通过根据锁表示去用其他锁竞争。 - 如果当前锁是偏向锁,则判断Mark Word对象头当前存储指向是否为当前线程,如果是,则说明当前线程已获得锁
- 如果MarkWord存储指向的线程不是当前线程,通过CAS方式去替换MarkWord线程信息,如果失败,则需要通过锁升级变为轻量级锁去完成锁的抢占过程。
- 如果替换成功,则获取偏向锁执行同步代码块
偏向锁的撤销
当另一个线程去尝试竞争偏向锁时,会触发偏向锁的撤销。
- 当线程二尝试用CAS方式替换MarkWord头信息失败时,触发偏向锁撤销操作。
- 偏向锁触发操作将在全局安全点时触发,全局安全点(SafePoint): 这个时间点上没有正在执行的字节码,即在此时间内,线程的状态,堆对象的状态是可以被确定的,在这个时间点上,JVM可以安全的执行GC等操作。
- 偏向锁触发操作开始后,会暂停已获得偏向锁的线程(线程1)的操作。
- 若获得偏向锁的线程已经执行完毕,或者非活状态,偏向锁会撤销为无锁状态,同时线程二升级为轻量级锁,进行资源抢占
- 若获得偏向锁的线程正在执行,会直接将锁对象升级为轻量级锁,并指向线程1,此时线程1持有轻量级锁,线程2进入竞争锁状态
关闭偏向锁
如果你确定你的线程永远在竞争状态,可以通过JVM参数进行关闭
-XX:-UseBiasedLocking=false
另外,偏向锁默认会在应用程序启动数秒之后才会被激活,如果想立即激活请执行
-XX:BiasedLockingStartupDelay=0
重新偏向
偏向锁一旦升级后,是不可逆,但是我们可以通过JVM启动参数来尝试重新偏向
-XX:BiasedLockingBulkRebiasThreshold=10
上述指令的意思时如果连续10次都是同一线程访问并获取锁(当前锁级别为轻量级锁),则会触发重新偏向,由轻量级锁转变为偏向锁。
轻量级锁
在偏向锁升级后或者主动关闭偏向锁后,程序执行synchronized
代码块时会使用轻量级锁来抢占资源。
轻量级锁的获取
- 当线程访问
synchronized
修饰的代码块时,会为当前线程分配一个锁记录空间,HotSpot源码中它为BasicObjectLock
对象 - 将无锁状态的Lock锁对象的MarkWord设置到当前线程的锁记录中。这样获取偏向锁的前置条件即完成。
- 通过CAS的方式修改Lock锁对象的MarkWord使其指向当前线程,成功,即获得轻量级锁,失败,则当前Lock锁对象不是无锁状态,膨胀为重量级锁
在多数材料,以及其他大部分网站对轻量级锁的描述中,轻量级锁获取锁失败,会出现自旋的过程,但是在HotSpot源码中,自旋操作是在膨胀到重量级锁的过程当中,也就说此时自旋只是在膨胀过程,并非自旋获取轻量级锁
轻量级锁的释放
轻量级锁释放,会使用CAS原子操作,将锁记录中无锁状态的Mark Word替换回到Lock锁对象的Mark Word中,如果这个过程顺利进行,则轻量级锁释放完成,否则触发膨胀机制,膨胀完成后再由重量级锁的方式进行解锁。
轻量级锁与偏向锁
总体来说,相比较偏向锁,轻量级锁的实现原理比较简单,功能上不同的地方就是:
- 偏向锁用于当前代码块中只被同一线程访问的场景
- 轻量级锁则为不同时间内的不同的线程访问代码块的场景
重量级锁
在轻量级锁加锁失败后,会膨胀为重量级锁。膨胀流程如下:
锁膨胀开始时,将创建检测对象ObjectMonitor
,然后通过CAS并自旋的方式尝试将Lock锁对象中的Mark Word 指向监测对象。
重量级锁的获取
在锁膨胀完成后,或者当前已经为重量级锁的等级下,开始重量级锁的加锁流程,具体流程如下:
- 首先线程尝试获取重量级锁,判断对象检测
ObjectMonitor
对象是否已经释放锁,成功则执行代码,失败则自旋重试 - 当达到一定自旋次数,也就说重入次数时,则会进入阻塞队列,等待释放锁的线程来唤醒它
重量级锁的释放
- 在对象检测
ObjectMonitor
对象中,有一个_owner
标识,将其修改为null
即表示当前线程已释放锁,可被其他线程自旋判断。 - 从阻塞队列当中唤醒一个阻塞线程
- 由于
synchronized
是一把非公平锁,所以如果一个阻塞线程被释放,这时又有一个新的线程在自旋判断是否监测对象已经释放锁,并且这时_owner
标识正好是已释放状态,这时很有可能新的线程得到锁,而刚被释放的线程再次进入阻塞状态。