首先,我们先来看下线程安全性的定义,为什么需要锁?
线程安全,即在多线程编程中,一个程序或者代码段在并发访问时,能够正确地保持其预期的行为和状态,而不会出现意外的错误或者不一致的结果。
而解决线程安全问题,主要分为两大类:1、无锁;2、有锁。
无锁的方式有:
- 局部变量;
- 对象加 final 为不可变对象;
- 使用 ThreadLocal 作为线程副本对象;
- CAS,Compare-And-Swap 即比较并交换,是 Java 十分常见的无锁实现方式。
小白:那有锁的方式呢,怎么通过加锁保证线程安全呢?
别急哈,下面听我给你一一道来。
Java 有哪些锁?
从加锁的策略看,分为隐式锁和显示锁。隐式锁通过 Synchronized 实现,显示锁通过 Lock 实现。
- 乐观锁:顾名思义,它是一种基于乐观的思想,认为读取的数据一般不会冲突,不会对其加锁,而是在最后提交数据更新时判断数据是否被更新,如果冲突,则更新不成功。
- 悲观锁:它总是假设最坏的情况,每次读取数据都认为别人会更新,所以每次读取数据的时候都会加锁,这样别人就得阻塞等待它处理完释放锁后才能去读取。
乐观锁实现:CAS,比较并交换,通常指的是这样一种原子操作:针对一个变量,首先比较它的内存值与某个期望值是否相同,如果相同,就给它赋一个新值。
但是,这一篇我们主要来看下悲观锁的一些常用实现。
syncroized 是什么?
syncronized 是 Java 中的一个关键字,用于控制对共享资源的并发访问,从而防止多个线程同时访问某个特定资源,这被称为同步。这个关键字可以用来修饰方法或代码块。
syncronized 使用对象锁保证临界区内代码的原子性
图片
小白:synchronized 的底层原理是什么呀,怎么自己就完成加锁释放锁操作了?
其实 synchronized 的原理也不难,主要有以下两个关键点。
- synchronized 又被称为监视器锁,基于 Monitor 机制实现的,主要依赖底层操作系统的互斥原语 Mutex(互斥量)。Monitor 类比加了锁的房间,一次只能有一个线程进入,进入房间即持有 Monitor,退出后就释放 Monitor。
- 另一个关键点是 Java 对象头,在 JVM 虚拟机中,对象在内存中的存储结构有三部分:对象头;实例数据;对齐填充。
对象头主要包括标记字段 Mark World,元数据指针,如果是数组对象的话,对象头还必须存储数组长度。
图片
synchronized 也是基于此,通过锁对象的 monitor 获取和 monitor 释放来实现,对象头标记为存储具体锁状态,ThreadId 记录持有偏向锁的线程 ID。
这里,又引申另外出一个问题:你知道什么是偏向锁呢?
小白:不知道,啥玩意?
synchronized 锁升级过程
说到这里,那就不得不提及 synchronized 的锁升级机制了,因为 synchronized 的加锁释放锁操作会使得 CPU 在内核态和户态之间发生切换,有一定性能开销。在 JDK1.5 版本以后,对 synchronized 做了锁升级的优化,主要利用轻量级锁、偏向锁、自适应锁等减少锁操作带来的开销,对其性能做了很大提升。
图片
- 无锁:没有对资源进行加锁
- 偏向锁:在大部分情况下,只有一个线程访问修改资源,该线程自动获取锁,降低了锁操作的代价,这里就通过对象头的 ThreadId 记录线程 ID。
- 轻量级锁:当前持有偏向锁,当有另外的线程来访问后,偏向锁会升级为轻量级锁,别的线程通过自旋形式尝试获取锁,不会阻塞,以提高性能。
- 重量级锁:在自旋次数或时间超过一定阈值时,最后会升级为重量级锁。
小白:哦哦原来如此,那刚刚你说了 Java 除了隐式锁之外,还有显示锁呢?
ReentrantLock 简介
在 Java 中,除了对象锁,还有显示的加锁的方式,比如 Lock 接口,用得比较多的就是 ReentrantLock。它的特性如下:
图片
下面我们再来对比看下 ReentrantLock 和 synchronized 的区别
图片
从这些对比就能看出 ReentrantLock 使用更加的灵活,特性更加丰富。
ReentrantLock 是一个悲观锁,即是同一个时刻,只允许一个线程访问代码块,这一点 synchronized 其实也一样。
图片
小白:这个是挺好用的,但是我们有一些读多写少的场景中比如缓存,大部分时间都是读操作,这里每个操作都要加锁,读性能不是很差吗,有没有更好的方案实现这种场景呀?
当然有的,比如 ReentrantReadWriteLock,读写锁。
ReentrantReadWriteLock 介绍
针对上述场景,Java 提供了读写锁 ReentrantReadWriteLock,它的内部维护了一对相关的锁,一个用于只读操作,称为读锁;一个用于写入操作,称为写锁。
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
final Sync sync;
使用核心代码如下:
public class LocalCacheService {
static Map<String, Object> localCache = new HashMap<>();
static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
static Lock readL = lock.readLock();
static Lock writeL = lock.writeLock();
public static Object read(String key) {
readL.lock();
try {
return localCache.get(key);
} finally {
readL.unlock();
}
}
public static Object save(String key, String value) {
writeL.lock();
try {
return localCache.put(key, value);
} finally {
writeL.unlock();
}
}
}
在 ReentrantReadWriteLock 中,多个线程可以同时读取一个共享资源。
当有其他线程的写锁时,读线程会被阻塞,反之一样。
图片
读写锁设计思路
这里有一个关键点,就是在 ReentrantLock 中,使用 AQS 的 state 表示同步状态,表示锁被一个线程重复获取的次数。但是在读写锁 ReentrantReadWriteLock 中,如何用一个变量维护这两个状态呢?
实际 ReentrantReadWriteLock 采用“高低位切割”的方式来维护,将 state 切分为两部分:高 16 位表示读;低 16 位表示写。
分割之后,通过位运算,假设当前状态为 S,那么:
- 写状态=S&0x0000FFFF(将高 16 位全部移除),当写状态需要加 1,S+1 再运算即可。
- 读状态=S>>>16(无符号补 0 右移 16 位),当读状态需要加 1,计算 S+(1<<16)。
图片
这时,我们再来思考下,如果有线程正在读,写线程需要等待读线程释放锁才能获取锁,也就是读的时候不允许写,那么有没有更好的方式改进呢?
小白:emm,这个真的难倒我了。。。。。。
什么是 StampedLock?
哈哈莫慌,Java8 已经引入了新的读写锁,StampedLock。它和 ReentrantReadWriteLock 相比,区别在于读过程允许获取写锁写入,在原来读写锁的基础上加了一种乐观锁机制,该模式不会阻塞写锁,只是最后会对比原来的值,有着更高的并发性能。
StampedLock 三种模式如下:
- 独占锁:和 ReentrantReadWriteLock 一样,同一时刻只能有一个写线程获取资源
图片
- 悲观读锁:允许多个线程获取读锁,但是读写互斥。
图片
- 乐观读:没有加锁,允许多个线程获取乐观读和读锁,同时允许一个写线程获取写锁。
图片
小白:那这里可以允许多个读操作和也给写线程同时进入共享资源操作,那读取的数据被改了怎么办啊??
别担心,乐观读不能保证读到的数据是最新的,所以当把数据读取到局部变量的时候需要通过 lock.validate 方法来校验是否被修改过,如果是改过了那么就加上悲观读锁,再重新读取数据到局部变量。