京东二面:Java中一共有 N 种实现锁的方式,你知道都有哪些吗?

作者:微信小助手

发布时间:2024-09-11T15:58:19

首先,我们先来看下线程安全性的定义,为什么需要锁?

线程安全,即在多线程编程中,一个程序或者代码段在并发访问时,能够正确地保持其预期的行为和状态,而不会出现意外的错误或者不一致的结果。

而解决线程安全问题,主要分为两大类:1、无锁;2、有锁。

无锁的方式有:

  1. 局部变量;
  2. 对象加 final 为不可变对象;
  3. 使用 ThreadLocal 作为线程副本对象;
  4. 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 做了锁升级的优化,主要利用轻量级锁、偏向锁、自适应锁等减少锁操作带来的开销,对其性能做了很大提升。

  1. 无锁:没有对资源进行加锁
  2. 偏向锁:在大部分情况下,只有一个线程访问修改资源,该线程自动获取锁,降低了锁操作的代价,这里就通过对象头的 ThreadId 记录线程 ID。
  3. 轻量级锁:当前持有偏向锁,当有另外的线程来访问后,偏向锁会升级为轻量级锁,别的线程通过自旋形式尝试获取锁,不会阻塞,以提高性能。
  4. 重量级锁:在自旋次数或时间超过一定阈值时,最后会升级为重量级锁。

小白:哦哦原来如此,那刚刚你说了 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 方法来校验是否被修改过,如果是改过了那么就加上悲观读锁,再重新读取数据到局部变量。