一文让你读懂java所有锁机制

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

一、synchronized与Lock

Java中有两种加锁的方式:一种是用synchronized关键字,另一种是用Lock接口的实现类。
首先要打消一种想法,就是一个锁只能属于一种分类。其实并不是这样,比如一个锁可以同时是悲观锁、可重入锁、公平锁、可中断锁等等。

图片.png

ReentrantLock、ReadLock、WriteLock 是Lock接口最重要的三个实现类。对应了“可重入锁”、“读锁”和“写锁”。

二、锁的种类

序号 锁名称 应用
1 乐观锁 CAS
2 悲观锁 synchronized、vector、hashtable
3 自旋锁 CAS
4 可重入锁 synchronized、Reentrantlock、Lock
5 读写锁 ReentrantReadWriteLock,CopyOnWriteArrayList、CopyOnWriteArraySet
6 公平锁 Reentrantlock(true)
7 非公平锁 synchronized、reentrantlock(false)
8 共享锁 ReentrantReadWriteLock中读锁
9 独占锁 synchronized、vector、hashtable、ReentrantReadWriteLock中写锁
10 重量级锁 synchronized
11 轻量级锁 锁优化技术
12 偏向锁 锁优化技术
13 分段锁 concurrentHashMap
14 互斥锁 synchronized
15 同步锁 synchronized
16 锁粗化 优化
17 锁消除 优化
1、悲观锁与乐观锁

锁的一种宏观分类方式是悲观锁和乐观锁。悲观锁与乐观锁并不是特指某个锁(Java中没有哪个Lock实现类就叫PessimisticLock或OptimisticLock),而是在并发情况下的两种不同策略。悲观锁(Pessimistic Lock), 就是很悲观,每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。这样别人想拿数据就被挡住,直到悲观锁被释放。乐观锁(Optimistic Lock), 就是很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!但是如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作)。

图片.png
图片.png

悲观锁阻塞事务,乐观锁回滚重试,它们各有优缺点,不要认为一种一定好于另一种。像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

data = 123; // 共享数据

/* 更新数据的线程会进行如下操作 */
flag = true;
while (flag) {
    oldValue = data; // 保存原始数据
    newValue = doSomething(oldValue); 

    // 下面的部分为CAS操作,尝试更新data的值
    if (data == oldValue) { // 比较
        data = newValue; // 设置
        flag = false; // 结束
    } else {
	// 啥也不干,循环重试
    }
}

这是一个简单直观的利用cas实现的乐观锁
复制代码
2、自旋锁

自旋锁是一种技术: 为了让线程等待,我们只须让线程执行一个忙循环(自旋)。

图片.png

自旋锁的优点: 避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。

自旋锁的缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。

3、可重入锁(递归锁)

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。可重入锁的作用: 避免死锁。

4、读写锁、共享锁、互斥锁

读写锁是一种技术: 通过ReentrantReadWriteLock类来实现。为了提高性能, Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的。

图片.png

看下Java里的ReadWriteLock接口,它只规定了两个方法,一个返回读锁,一个返回写锁。

// 获取读锁
rwLock.readLock().lock();

// 释放读锁
rwLock.readLock().unlock();
复制代码
// 创建一个写锁
rwLock.writeLock().lock();

// 写锁 释放
rwLock.writeLock().unlock();
复制代码
5、公平锁、非公平锁

如果多个线程申请一把公平锁,那么当锁释放的时候,先申请的先得到,非常公平。显然如果是非公平锁,后申请的线程可能先获取到锁,是随机或者按照其他优先级排序的。
对于synchronized而言,它也是一种非公平锁,但是并没有任何办法使其变成公平锁。

6、独占锁

独占锁是一种思想: 只能有一个线程获取锁,以独占的方式持有锁。和悲观锁、互斥锁同义。

Java中用到的独占锁: synchronized,ReentrantLock

7、synchronized锁升级:偏向锁 → 轻量级锁 → 重量级锁

有人把synchronized关键字比喻是汽车的自动档,一脚油门踩下去,synchronized会从无锁升级为偏向锁,再升级为轻量级锁,最后升级为重量级锁,就像自动换挡一样。那么自旋锁在哪里呢?这里的轻量级锁就是一种自旋锁。

偏向锁的设计理念:由于每次进入和退出同步块都需要获取和释放锁,十分浪费资源。经过大量的验证,发现很多情况下都是同一个线程来获取锁。于是就理想化的让这个锁一直给这个线程。要保证锁是由一个线程来获取,就必须在锁的对象头上添加此线程的 ID。于是偏向锁状态下,Mark Word 会记录:线程对象的 HashCode,分代年龄,是否偏向锁,锁标志。执行流程:当锁第一次被线程获取,就将线程 Hashcode 添加到锁的对象头里。线程执行完后并不释放锁。当第二次获取锁,会先判断此线程是否和对象头记录的线程一致,一致的话就直接运行同步代码。若不一致,则锁会升级/膨胀,变成轻量级锁。

优点:在没有竞争或者只有一个线程使用锁的情况下,偏向锁节省了获取和释放锁对性能的损耗。

轻量级锁状态下,Mark Word 会记录:指向线程栈中锁记录的指针,锁标志位。

优点:避免在了线程的阻塞,当线程获取不到锁时,会进行自旋,而不会阻塞,造成系统调用内核态和用户态。

重量级锁是依赖对象内部的 monitor 锁来实现的,而 monitor 又依赖操作系统的 MutexLock(互斥锁)来实现,所以重量级锁也称为互斥锁。

重量级锁需要阻塞线程,唤醒线程,释放锁,消耗资源很大。

图片.png

8、分段锁

分段锁是一种机制: 最好的例子来说明分段锁是ConcurrentHashMap。ConcurrentHashMap原理:它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。如果需要在 ConcurrentHashMap 添加一项key-value,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该key-value应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行put操作,只要被加入的key-value不存放在同一个段中,则线程间可以做到真正的并行。

线程安全:ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全

9、锁粗化

锁粗化的概念就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:

public class StringBufferTest {
    StringBuffer stringBuffer = new StringBuffer();

    public void append(){
        stringBuffer.append("a");
        stringBuffer.append("b");
        stringBuffer.append("c");
    }
}

复制代码

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

10、锁消除(Lock Elimination)

锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }

复制代码

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。

总结

其实Java本身已经对锁本身进行了良好的封装,降低了研发同学在平时工作中的使用难度。但是开发的时候也需要熟悉锁的底层原理,不同场景下选择最适合的锁。而且源码中的思路都是非常好的思路,也是值得大家去学习和借鉴的。
锁优点缺点适用场景偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。同步块执行速度非常快。重量级锁线程竞争不使用自旋,不会消耗CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行速度较长。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享