写点什么

Java 的锁

用户头像
关注
发布于: 2021 年 03 月 05 日

本文试图回答以下几个问题。

什么是锁?

常见的锁的类型有哪些?

锁有什么用?

Java 对锁的处理和使用锁的最佳实践是什么?

锁到底解决了什么问题?

想分析明白这个问题,那么我们先看一下,锁到底都用在哪些场景?这些场景下为什么要用锁?如果没有锁?能实现吗?会带来什么问题?

场景 1:数据库悲观锁(下单场景)

start transaction;select goods from goods where id=1 for update;#查询insert into order(id,goods_id) value(1,1);#下单update goods set stock=stock-1 where id=1 and stock>=1;#减库存insert into order(id,goods_id) value(1,1);#下单commit;
复制代码

场景 2:java 中并发场景

public class ConcurrentTest {
public static int clientTotal = 5000;
public static int threadTotal = 200;
public static int count = 0;
public static void main(String[] args) throws Exception { ExecutorService executorService = Executors.newCachedThreadPool(); final Semaphore semaphore = new Semaphore(threadTotal); final CountDownLatch countDownLatch = new CountDownLatch(clientTotal); for (int i = 0; i < clientTotal ; i++) { executorService.execute(() -> { try { semaphore.acquire(); add(); semaphore.release(); } catch (Exception e) { System.out.println(e); } countDownLatch.countDown(); }); } countDownLatch.await(); executorService.shutdown(); System.out.println("count:"+count); }
private static void add() { count++; }
}
复制代码


我们可以假设,场景 1,如果没有对库存加锁,两个用户同时购买同一个库存为 1 的商品的话,极易造成超卖的问题。场景 2,代码大家可以直接复制到 IDE 执行一下,看看执行结果是否和你预想的一致。(这里就是我们常常提到的线程安全问题

通过以上两个例子,我们发现,上面场景中,有两个共同特点。1.存在共享的资源(也就是临界区或临界资源)2.有大量的用户/线程在竞争这些资源。

线程安全问题

并发编程中的三个重要概念。

原子性:一个操作,要么全部成功,要么全部失败,不可中断。

可见性:一个共享的多线程变量,一个线程修改了,其他线程是可以实时看到修改结果的。

有序性:程序执行的顺序按照代码编写的顺序执行。

那么,并发环境中,到底为什么会出现以上三个问题呢?因为计算机中的任务都是抢夺 CPU 时间片执行的,如果失去了 CPU 调度的任务,很可能会执行到一半就被暂停。这时候,就会产生原子性问题。一个操作被中断了。大家可以类比一下经典的转账问题。这里举个简单的例子,i++这个操作就是不具有原子性的。i=10 就是一个具有原子性的操作。可见性问题的根本原因,是因为 Java 虚拟机的内存模型决定的。线程私有的本地内存,会对主存中的变量拷贝操作。这样的设计提高了效率,避免了重复访问主存。但是,也带来了共享变量的可见性问题。如果 2 个线程,同时更改了本地内存中的某个共享变量的值, 那么他们都将新的值刷入主存,就一定会有问题。有序性是编译器和处理器进行指令重排序和指令优化所带来的问题。我们写的代码,编译执行的时候,计算机会按自己的方式去优化执行我们的代码,以提高效率。这时就会带来有序性的问题。即我们的代码,真正执行时,很可能不是按我们写代码的顺序执行的。

锁是什么

本质上,锁就是一个内存中的整数。各种高级语言,所用的锁,其实都是封装调用操作系统提供的接口。那么,操作系统又是如何实现锁的?操作系统,其实又要依赖低层硬件提供的几个功能,来实现锁。如:

硬件层面,CPU 提供了原子操作、锁内存总线机制。在 cpu 芯片上有一个 HLOCK Pin,可以通过发送指令来操作。

锁的基本概念及分类

锁的分类根据视角不同,大致可分为以下几类:

悲观锁 VS 乐观锁

顾名思义,悲观锁悲观的认为,我要修改的资源,一定会被别的线程竞争和修改。所以,在操作前,我就对资源上锁,避免我的操作失败。其它想要竞争资源的线程,竞争失败,只能入队等待。而乐观锁,则乐观的认为,我要操作的资源,不会被其它线程所修改。在真正写入的时候,如果发现被修改了,再上锁,或者报错。

根据以上定义,我们可以看出。悲观锁,适合写多读少的场景,保证了写入效率。乐观锁,适合读多写少的场景,保证读取效率。

那么这里,其实又引出一个问题。乐观锁是如何做到,不对资源加锁,也能保障读写正确的呢?这里介绍一下乐观锁常见的实现方式 CAS。

CAS 全称 Compare And Swap。这里涉及三个重要的变量,V:表示要更新的变量。E:预期新值。N:新值。只有在 V=E 的时候,就将 N 赋值给 V,否则什么都不做。有兴趣的可以看一下源码,这里用的是一个循环实现的。如果赋值的操作失败,那么会在循环中不停的进行赋值,直到赋值成功。(CAS 其实还有 ABA 和性能问题,这点读者可以自行检索)

自旋锁 VS 适应性自旋锁

首先,大家要先了解一个场景。进程/线程的切换,是极其消耗系统资源的。如果,临界区的代码只有非常简单的逻辑,那么线程间切换的时间,相比于真正的业务代码执行的时间还要长。所有,为了节省这一段时间,引入了自旋锁。

假如,CPU 有多个核并行执行任务。如果 A 核持有锁,正在执行任务的时候。B 核也同时运行着线程来竞争 A 核持有的锁。这是,B 核竞争失败,正常情况下,会将任务放到队列中。等待 A 核执行完毕,唤醒下一个线程。引入自旋锁以后,B 核不直接放弃任务,而是通过循环实现自旋,来等待锁。当然, 这个自旋也是有次数限制的,如果无限自旋下去,显然是舍本逐末。

适应性自旋锁,顾名思义, 是对自旋锁的一个优化。对同一个锁上竞争的线程,记录他们获取锁的情况,根据实际情况,自动调节自旋次数,避免浪费 CPU 时间。

偏向锁 VS 轻量级锁 VS 重量级锁

这是 Java 中的概念。

Java 中的锁共有四种状态:无锁、偏向锁、轻量级锁、重量级锁。

无锁就不用详细说了吧。

偏向锁:一段代码 a,持续被同一个线程访问,这个线程就自动获取锁。如果 a 调用了 b 方法,如果 a 和 b 都用 synchronized 修饰,那么,线程还是要 CAS 来获取 b 的锁。这时候,判断偏向锁中 thread_id 的值,与当前值相等,那么直接获得锁,无需循环(提高效率)。

轻量级锁:代码 a 被锁住的时候,如果其它线程过来争抢锁,偏向锁自动升级为轻量级锁。其它线程会自旋,尝试获取锁,不会阻塞(提高性能)。

重量级锁:竞争线程,虽然在自旋,但是不会无限自旋下去。超过阈值以后,就会进入阻塞,当前锁升级为重量级锁。(重量级锁,会将其它前来竞争的线程,都加入一个 monitor 队列,此时,进入阻塞状态,性能降低)。

公平锁 VS 非公平锁

公平锁:所有参与竞争的线程,都加入一个 monitor 队列,排队依次获得锁。公平锁不会发生饥饿现象,但是效率稍低。

非公平锁:锁的顺序是随机的,锁分配没有什么既定规则,竞争的线程也会加入到队列等待,但是等待的线程,可能会产生饥饿现象(就是,有的线程,始终分配不到锁)。非公平锁所在意的是吞吐量,为了减少唤醒线程的开销,优先选择能直接分配锁的线程。

可重入锁 VS 非可重入锁

可重入锁:某个线程已经获得锁,可以重复获取锁,而不发生死锁现象。(Synchronized 和 ReentrantLock 都是可重入锁)

独享锁/互斥锁 VS 共享锁 VS 读写锁

独享锁/互斥锁:该锁只能被一个线程占有

共享锁:一个锁可以有多个线程持有(如可以规定,一个所可以有 3 个线程持有),获得共享锁的线程,只能读,不能写

读写锁:读操作不互斥,写操作与任何读写操作都互斥

分段锁

这不是一个锁的概念,这是一个锁的应用。具体的思想为:当不需要对整个数组加锁时,就将数组分段,分别加锁。ConcurrentHashMap 的实现,就用到了分段锁。分段锁最大并发度等于锁的个数。


看完了上面的介绍,我相信大家一定更晕了。知道了锁的作用,但是为什么锁有这么多的种类?为什么锁会这么复杂?个人认为,造成锁如此复杂的原因有两点:1.因为加解锁是极其消耗系统资源的一种操作,很多锁,其实都是在解决如何尽量避免加锁,如何最小粒度的加锁,如何更快的解锁。2.现在计算机都是多核运行,多个 CPU 都有自己 CPU 级的一二级缓存,和内存缓存。解决这些缓存数据的一致性问题,势必会将整个过程变得无比复杂。

Java 中锁的实现

JVM 中的一个对象由三部分组成:对象头、实例数据、填充数据。


对象头由两部分组成:Mark Word、Klass Pointer。

Klass Pointer:类型指针,指向类的元信息。我们这里不做详细介绍。

Mark Word:记录 hash code、GC 年龄、线程 id 等信息。

锁主要和 Mark Word 相关,所以这里主要介绍 Mark Word。Mark Word 大小为 64bits,其中最后两位为锁标识位。

无锁:01

偏向锁:01

轻量级锁:00

重量级锁:10

大家可以看到,这里的无锁和偏向锁都是 01,那么 JVM 是如何区分两种锁的呢?Mark Word 中还有一位 biased_lock,占 1bit,0 表示无锁。1 表示偏向锁。这样,使用了 3bit 的空间,能灵活的表示 5 种状态,极大地节省了内存空间。

ReentrantLock 和 Synchronized 的比较

区别

1.Synchronized 是语言级别支持的关键字,需要 JVM 实现。ReentrantLock 是 JDK1.5 之后提供的 api。

2.Synchronized 默认是公平锁。ReentrantLock 可以自行设置使用公平锁还是非公平锁以及一些其它更细粒度的锁的监控使用等 api

3.Synchronized 加锁解锁完全由 JVM 控制。ReentrantLock 更加灵活,开发者可以自行控制锁。

4.一般情况下认为 ReentrantLock 的效率比 Synchronized 效率更高。资源消耗更少

相同点

二者都是可重入锁

Synchronized

java 中,不管你锁的是对象还是方法还是代码块,最终,锁都会锁到对象上去。方法锁的其实是 synchronized(this)

ReentrantLock

1.手动设置公平锁、非公平锁

2.可以用到与 ReentrantLock 配合使用的 Condition 类,来分组唤醒等待的线程

3.提供能够中断等待锁的线程机制,lock.lockInterruptibly()

“一句话”总结

Synchronized 可以完成 90%的用到锁的场景,性能、易用性方面与 ReentrantLock 几乎相同。ReentrantLock 不建议初级程序员使用。

Synchronized 优化后加入的偏向锁、轻量级锁等可能也是借鉴了 ReentrantLock 的设计思想,引入 CAS 来实现的。

偏向锁通过对比 Mark Word 中的 Thread id 来解决加锁的问题。提高同一个线程反复执行同一段临界区代码的效率。如果发生竞争,则产生额外的开销,升级为轻量级锁。

轻量级锁则是通过 CAS 自旋来解决加锁的问题。当前线程,将 Mark Word 中的记录,改为指针,指向栈帧中的锁记录(Lock Reocrd),成功则获取锁,失败则证明还有竞争,升级为重量级锁。

重量级锁则是将所有竞争锁的线程都阻塞。只有当前持有锁的线程正常执行。


参考资料:

《Java 并发编程实战》

Java 对象头分析与使用(Synchronized 相关):https://www.jianshu.com/p/00c77855fc7c

Java 锁的那些事儿:https://tech.youzan.com/javasuo-de-na-xie-shi-er/

不可不说的 Java“锁”事:https://tech.meituan.com/2018/11/15/java-lock.html

synchronized 实现原理:https://xiaomi-info.github.io/2020/03/24/synchronized/

发布于: 2021 年 03 月 05 日阅读数: 18
用户头像

关注

还未添加个人签名 2018.05.02 加入

还未添加个人简介

评论

发布
暂无评论
Java的锁