Java 锁
-
synchronized
-
volatile
synchronized 的原理是什么?
synchronized是 Java 内置的关键字,它提供了一种独占的加锁方式。
-
synchronized
的获取和释放锁由JVM实现,用户不需要显示的释放锁,非常方便。 - 然而,
synchronized
也有一定的局限性。- 当线程尝试获取锁的时候,如果获取不到锁会一直阻塞。
- 如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待。
关于原理,直接阅读 《【死磕 Java 并发】—– 深入分析 synchronized 的实现原理》 文章,有几个重点要注意看。
1 实现原理
synchronized
可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。
Java 中每一个对象都可以作为锁,这是 synchronized
实现同步的基础:
- 普通同步方法,锁是当前实例对象
- 静态同步方法,锁是当前类的 class 对象
- 同步方法块,锁是括号里面的对象
当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:
public class SynchronizedTest {
public synchronized void test1() {
}
public void test2() {
synchronized(this) {
}
}
利用 Javap 工具查看生成的 class 文件信息来分析 synchronized
的实现
从上面可以看出:
1)同步代码块是使用 monitorenter
和 monitorexit
指令实现的;
2)同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED
实现。
-
同步代码块:
monitorenter
指令插入到同步代码块的开始位置,monitorexit
指令插入到同步代码块的结束位置,JVM 需要保证每一个monitorenter
都有一个monitorexit
与之相对应。任何对象都有一个 Monitor 与之相关联,当且一个 Monitor 被持有之后,他将处于锁定状态。线程执行到monitorenter
指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁。 -
同步方法:
synchronized
方法则会被翻译成普通的方法调用和返回指令如:invokevirtual
、areturn
指令,在 VM 字节码层面并没有任何特别的指令来实现被synchronized
修饰的方法,而是在 Class 文件的方法表中将该方法的access_flags
字段中的synchronized
标志位置设置为 1,表示该方法是同步方法,并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 作为锁对象。
下面我们来继续分析,但是在深入之前我们需要了解两个重要的概念:Java对象头,Monitor。
2 Java 对象头、Monitor
Java 对象头和 Monitor 是实现 synchronized
的基础!下面就这两个概念来做详细介绍。
2.1 Java对象头
synchronized
用的锁是存在Java对象头里的。那么什么是 Java 对象头呢?Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中:
- Klass Point 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键,所以下面将重点阐述 Mark Word 。
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bits)。但是如果对象是数组类型,则需要三个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
下图是 Java 对象头的存储结构(32位虚拟机):
存储结构对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word 会随着程序的运行发生变化,变化状态如下:
-
32 位虚拟机:
32 位虚拟机 -
64 位虚拟机:
image
简单介绍了 Java 对象头,我们下面再看 Monitor。
2.2 Monitor
什么是 Monitor ?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的 Java 对象是天生的 Monitor ,每一个 Java 对象都有成为Monitor 的潜质,因为在 Java 的设计中 ,每一个 Java 对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁。
FROM 《Java并发编程的艺术》的 「2.2 synchronized 的实现原理与引用」 章节。
Monitor Record 是线程私有的数据结构,每一个线程都有一个可用 Monitor Record 列表,同时还有一个全局的可用列表。
每一个被锁住的对象都会和一个 Monitor Record 关联(对象头的 MarkWord 中的 LockWord 指向 Monitor 的起始地址),Monitor Record 中有一个 Owner 字段,存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下:
Monitor Record
- Owner:1)初始时为 NULL 表示当前没有任何线程拥有该 Monitor Record;2)当线程成功拥有该锁后保存线程唯一标识;3)当锁被释放时又设置为 NULL 。
我们知道 synchronized
是重量级锁,效率不怎么滴,同时这个观念也一直存在我们脑海里,不过在 JDK 1.6 中对 synchronize
的实现进行了各种优化,使得它显得不是那么重了,那么 JVM 采用了那些优化手段呢?
3. 锁优化
FROM 《JVM 内部细节之一:synchronized 关键字及实现细节(轻量级锁Lightweight Locking)》
简单来说,在 JVM 中
monitorenter
和monitorexit
字节码依赖于底层的操作系统的Mutex Lock 来实现的,但是由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。然而,在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境),如果每次都调用 Mutex Lock 那么将严重的影响程序的性能。
因此,JDK 1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
3.1 自旋锁
由来
线程的阻塞和唤醒,需要 CPU 从用户态转为核心态。频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时,我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
定义
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。
怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。
所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用 -XX:+UseSpinning
开开启。
在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin
来调整。
如果通过参数 -XX:PreBlockSpin
来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为 10 ,但是系统很多线程都是等你刚刚退出的时候,就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是 JDK 1.6 引入自适应的自旋锁,让虚拟机会变得越来越聪明。
3.2 适应自旋锁
JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?
- 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
- 反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
3.3 锁消除
由来
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制。但是,在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。
定义
锁消除的依据是逃逸分析的数据支持。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐性的加锁操作。比如 StringBuffer 的 #append(..)
方法,Vector 的 add(...)
方法:
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for (int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
在运行这段代码时,JVM 可以明显检测到变量 vector
没有逃逸出方法 #vectorTest()
之外,所以 JVM 可以大胆地将 vector
内部的加锁操作消除。
3.4 锁粗化
由来
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小:仅在共享数据的实际作用域中才进行同步。这样做的目的,是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的,LZ 也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。
定义
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
如上面实例:vector
每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector
)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for
循环之外。
当一个线程进入某个对象的一个 synchronized
的实例方法后,其它线程是否可进入此对象的其它方法?**
- 如果其他方法没有
synchronized
的话,其他线程是可以进入的。 - 所以要开放一个线程安全的对象时,得保证每个方法都是线程安全的。
同步方法和同步块,哪个是更好的选择?
同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
同步块更要符合开放调用的原则,只在需要锁住的代码块锁住相应的对象,这样从侧面来说也可以避免死锁。
在监视器(Monitor)内部,是如何做线程同步的?
监视器和锁在 Java 虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码。
Java 如何实现“自旋”(spin)
public class SpinLock {
private AtomicReference<Thread> sign =new AtomicReference<>();
public void lock() { // <1>
Thread current = Thread.currentThread();
while(!sign .compareAndSet(null, current)) {
// <1.1>
}
}
public void unlock () { // <2>
Thread current = Thread.currentThread();
sign.compareAndSet(current, null);
}
}
-
<1>
处,#lock()
方法,如果获得不到锁,就会“死循环”,直到或得到锁为止。考虑到“死循环”会持续占用 CPU ,可能导致其它线程无法获得到 CPU 执行,可以在<1.1>
处增加Thread.yiead()
代码段,出让下 CPU 。 -
<2>
处,#unlock()
方法,释放锁。
volatile 实现原理
我们了解了 synchronized
是一个重量级的锁,虽然 JVM 对它做了很多优化。而 volatile
,则是轻量级的 synchronized
,它在多线程开发中保证了共享变量的“可见性”。如果一个变量使用 volatile
,则它比用 synchronized
的成本更加低,因为它不会引起线程上下文的切换和调度。
Java 语言规范对 volatile
的定义如下:
Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
上面比较绕口,通俗点讲就是说一个变量如果用 volatile
修饰了,则 Java 可以确保所有线程看到这个变量的值是一致的。如果某个线程对 volatile
修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性。
volatile
虽然看起来比较简单,使用起来无非就是在一个变量前面加上 volatile
即可,但是要用好并不容易。
1. 内存模型相关概念
理解 volatile
其实还是有点儿难度的,它与 Java 的内存模型有关,所以在理解 volatile
之前我们需要先了解有关 Java 内存模型的概念。
1.1 操作系统语义
计算机在运行程序时,每条指令都是在 CPU 中执行的,在执行过程中势必会涉及到数据的读写。我们知道程序运行的数据是存储在主存中,这时就会有一个问题,读写主存中的数据没有 CPU 中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了 CPU 高速缓存。CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。
有了 CPU 高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性。在程序运行中,会将运行所需要的数据复制一份到 CPU 高速缓存中,在进行运算时 CPU 不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后,才会将数据刷新到主存中。举一个简单的例子:
i = i + 1;
当线程运行这段代码时,首先会从主存中读取 i
的值( 假设此时 i = 1
),然后复制一份到 CPU 高速缓存中,然后 CPU 执行 + 1
的操作(此时 i = 2
),然后将数据 i = 2
写入到告诉缓存中,最后刷新到主存中。
其实这样做在单线程中是没有问题的,有问题的是在多线程中。如下:
假如有两个线程 A、B 都执行这个操作( i++
),按照我们正常的逻辑思维主存中的i值应该=3
。但事实是这样么?分析如下:
两个线程从主存中读取 i
的值( 假设此时 i = 1
),到各自的高速缓存中,然后线程 A 执行 +1
操作并将结果写入高速缓存中,最后写入主存中,此时主存 i = 2
。线程B做同样的操作,主存中的 i
仍然 =2
。所以最终结果为 2 并不是 3 。这种现象就是缓存一致性问题。
解决缓存一致性方案有两种:
- 通过在总线加 LOCK# 锁的方式
- 通过缓存一致性协议
第一种方案, 存在一个问题,它是采用一种独占的方式来实现的,即总线加 LOCK# 锁的话,只能有一个 CPU 能够运行,其他 CPU 都得阻塞,效率较为低下。
第二种方案,缓存一致性协议(MESI 协议),它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个 CPU 在写数据时,如果发现操作的变量是共享变量,则会通知其他 CPU 告知该变量的缓存行是无效的,因此其他 CPU 在读取该变量时,发现其无效会重新从主存中加载数据。
2122193437836991.2 Java内存模型
上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下 Java 内存模型,稍微研究一下它为我们提供了哪些保证,以及在 Java 中提供了哪些方法和机制,来让我们在进行多线程编程时能够保证程序执行的正确性。
在并发编程中我们一般都会遇到这三个基本概念:原子性、可见性、有序性。我们稍微看下volatile
。
1.2.1 原子性
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性就像数据库里面的事务一样,他们是一个团队,同生共死。其实理解原子性非常简单,我们看下面一个简单的例子即可:
i = 0; // <1>
j = i ; // <2>
i++; // <3>
i = j + 1; // <4>
上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有 1 才是原子操作,其余均不是。
-
<1>
:在 Java 中,对基本数据类型的变量和赋值操作都是原子性操作。 -
<2>
:包含了两个操作:读取i
,将i
值赋值给j
。 -
<3>
:包含了三个操作:读取i
值、i + 1
、将+1
结果赋值给i
。 -
<4>
:同<3>
一样
在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java 只保证了基本数据类型的变量和赋值操作才是原子性的(注:在 32 位的 JDK 环境下,对 64 位数据的读取不是原子性操作,例如:long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized
来确保。
另外,volatile
是无法保证复合操作的原子性
1.2.2 可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。
Java提供了 volatile
来保证可见性。
当一个变量被 volatile
修饰后,表示着线程本地内存无效。当一个线程修改共享变量后他会立即被更新到主内存中;当其他线程读取共享变量时,它会直接从主内存中读取。
当然,synchronize
和锁都可以保证可见性。
1.2.3 有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
在 Java 内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。
Java 提供 volatile
来保证一定的有序性。最著名的例子就是单例模式里面的 DCL(双重检查锁)。
2. 剖析 volatile 原理
JMM 比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了 volatile
做铺垫的。
volatile
可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层,volatile
是采用“内存屏障”来实现的。
上面那段话,有两层语义:
- 保证可见性、不保证原子性
- 禁止指令重排序
第一层语义就不做介绍了,下面重点介绍指令重排序。
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
- 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答。
我们先看另一个原则 happens-before:该原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从 happens-before 原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before 于书写在后面的操作。
- 锁定规则:一个 unLock 操作,happens-before 于后面对同一个锁的 lock 操作。
- volatile 变量规则:对一个变量的写操作,happens-before 于后面对这个变量的读操作。
- 传递规则:如果操作 A happens-before 操作 B,而操作 B happens-before 操作C,则可以得出,操作 A happens-before 操作C
- 线程启动规则:Thread 对象的 start 方法,happens-before 此线程的每个一个动作。
- 线程中断规则:对线程 interrupt 方法的调用,happens-before 被中断线程的代码检测到中断事件的发生。
- 线程终结规则:线程中所有的操作,都 happens-before 线程的终止检测,我们可以通过Thread.join() 方法结束、Thread.isAlive() 的返回值手段,检测到线程已经终止执行。
- 对象终结规则:一个对象的初始化完成,happens-before 它的 finalize() 方法的开始
我们着重看第三点 Volatile规则:对 volatile
变量的写操作,happen-before 后续的读操作。为了实现 volatile
内存语义,JMM会重排序,其规则如下:
- 当第二个操作是
volatile
写操作时,不管第一个操作是什么,都不能重排序。这个规则,确保volatile
写操作之前的操作,都不会被编译器重排序到volatile
写操作之后。
对 happen-before 原则有了稍微的了解,我们再来回答这个问题 JVM 是如何禁止重排序的?
观察加入 volatile
关键字和没有加入 volatile
关键字时所生成的汇编代码发现,加入volatile
关键字时,会多出一个 lock 前缀指令。lock 前缀指令,其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile
的底层就是通过内存屏障来实现的。下图是完成上述规则所需要的内存屏障:
volatile 有什么用?
volatile
保证内存可见性和禁止指令重排。
同时,
volatile
可以提供部分原子性。
简单来说,volatile
用于多线程环境下的单次操作(单次读或者单次写)。
volatile 变量和 atomic 变量有什么不同?
-
volatile
变量,可以确保先行关系,即写操作会发生在后续的读操作之前,但它并不能保证原子性。例如用volatile
修饰count
变量,那么count++
操作就不是原子性的。 - AtomicInteger 类提供的 atomic 方法,可以让这种操作具有原子性。例如
#getAndIncrement()
方法,会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。
可以创建 volatile
数组吗?
Java 中可以创建 volatile
类型数组,不过只是一个指向数组的引用,而不是整个数组。如果改变引用指向的数组,将会受到 volatile
的保护,但是如果多个线程同时改变数组的元素,volatile
标示符就不能起到之前的保护作用了。
同理,对于 Java POJO 类,使用 volatile
修饰,只能保证这个引用的可见性,不能保证其内部的属性。
volatile 能使得一个非原子操作变成原子操作吗?
一个典型的例子是在类中有一个 long
类型的成员变量。如果你知道该成员变量会被多个线程访问,如计数器、价格等,你最好是将其设置为 volatile
。为什么?因为 Java 中读取 long
类型变量不是原子的,需要分成两步,如果一个线程正在修改该 long
变量的值,另一个线程可能只能看到该值的一半(前 32 位)。但是对一个 volatile
型的 long
或 double
变量的读写是原子。
如下的内容,可以作为上面的内容的补充。
一种实践是用
volatile
修饰long
和double
变量,使其能按原子类型来读写。double
和long
都是64位宽,因此对这两种类型的读是分为两部分的,第一次读取第一个 32 位,然后再读剩下的 32 位,这个过程不是原子的,但 Java 中volatile
型的long
或double
变量的读写是原子的。
volatile 和 synchronized 的区别?
-
volatile
本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取。synchronized
则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。 -
volatile
仅能使用在变量级别。synchronized
则可以使用在变量、方法、和类级别的。 -
volatile
仅能实现变量的修改可见性,不能保证原子性。而synchronized
则可以保证变量的修改可见性和原子性。 -
volatile
不会造成线程的阻塞。synchronized
可能会造成线程的阻塞。 -
volatile
标记的变量不会被编译器优化。synchronized
标记的变量可以被编译器优化。
另外,会有面试官会问
volatile
能否取代synchronized
呢?答案肯定是不能,虽然说volatile
被称之为轻量级锁,但是和synchronized
是有本质上的区别,原因就是上面的几点落。
🦅 什么场景下可以使用 volatile
替换 synchronized
?
- 只需要保证共享资源的可见性的时候可以使用
volatile
替代,synchronized
保证可操作的原子性一致性和可见性。 -
volatile
适用于新值不依赖于旧值的情形。 - 1 写 N 读。
- 不与其他变量构成不变性条件时候使用
volatile
。
什么是死锁、活锁?
死锁,是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
产生死锁的必要条件:
- 互斥条件:所谓互斥就是进程在某一时间内独占资源。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
死锁的解决方法:
- 撤消陷于死锁的全部进程。
- 逐个撤消陷于死锁的进程,直到死锁不存在。
- 从陷于死锁的进程中逐个强迫放弃所占用的资源,直至死锁消失。
- 从另外一些进程那里强行剥夺足够数量的资源分配给死锁进程,以解除死锁状态。
🦅 什么是活锁?
活锁,任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。
🦅 死锁与活锁的区别?
活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
实际上,聪慧的胖友是不是已经发现,死锁就是悲观锁可能产生的结果,而活锁是乐观锁可能产生的结果。
什么是悲观锁、乐观锁?
1)悲观锁
悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
- 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
- 再比如 Java 里面的同步原语
synchronized
关键字的实现也是悲观锁。
2)乐观锁
乐观锁,顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。
-
像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。
例如,version 字段(比较跟上一次的版本号,如果一样则更新,如果失败则要重复读-比较-写的操作)
-
在 Java 中
java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁的实现方式:
- 使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
- Java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。