线程安全
1. 讲一下 synchronized 关键字的底层原理
1.1 基本使用
如下抢票的代码,如果不加锁,就会出现超卖或者一张票卖给多个人
synchronized,同步【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
其它线程再想获取这个【对象锁】时就会阻塞
public class TicketDemo {
static Object lock = new Object();
int ticketNum = 10;
public synchronized void getTicket() {
synchronized (this) {
if (ticketNum <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
// 非原子性操作
ticketNum--;
}
}
public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}
}
1.2 Monitor
Monitor 被翻译为监视器,是由 JVM 提供,C++ 语言实现
在代码中想要体现 Monitor 需要借助 javap 命令查看 class 的字节码,比如以下代码:
public class SyncTest {
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
找到这个类的 class 文件,在 class 文件目录下执行javap -v SyncTest.class
,反编译效果如下:
- monitorenter 上锁开始的地方
- monitorexit 解锁的地方
- 其中被 monitorenter 和 monitorexit 包围住的指令就是上锁的代码
- 有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁
在使用了 synchornized 代码块时需要指定一个对象,所以 synchornized 也被称为对象锁
加锁的变量必须是“对象实例”,不能是基本数据类型的变量
Monitor 主要就是跟这个对象产生关联,如下图
Monitor内部具体的存储结构:
-
Owner:存储当前获取锁的线程的,只能有一个线程可以获取
-
EntryList:关联没有抢到锁的线程,处于 Blocked 状态的线程
-
WaitSet:关联调用了 wait 方法的线程,处于 Waiting 状态的线程
具体的流程:
- 代码进入 synchorized 代码块,先让 lock(对象锁)关联的 monitor,然后判断 Owner 是否有线程持有
- 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功;
- 如果有线程持有,则让当前线程进入 entryList 进行阻塞,如果Owner持有的线程已经释放了锁,在 EntryList中的线程去竞争锁的持有权(非公平);
- 如果代码块中调用了 wait() 方法,则会进去 WaitSet 中进行等待;
参考回答:
-
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
-
它的底层由 Monitor 实现的,Monitor 是 JVM 级别的对象( C++实现),线程获得锁需要使用对象(锁)关联 Monitor
-
在 Monitor 内部有三个属性,分别是 Owner、EntryList、WaitSet,其中
- Owner是关联的获得锁的线程,并且只能关联一个线程;
- EntryList 关联的是处于 Blocked 状态的线程;
- WaitSet 关联的是处于 Waiting 状态的线程
2. Monitor 实现的锁属于重量级锁,你了解过锁升级吗?
- Monitor 实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
- 在 JDK 1.6 引入了两种新型锁机制:
- 偏向锁
- 轻量级锁
- 它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
2.1 对象的内存结构
大部分的 Java 对象都会存储在 HotSpot 的堆内存中,至少可以加锁的对象都在;
在 HotSpot 虚拟机中,对象在内存中存储的布局可分为 3 块区域:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充
对象头(Header) 还分为两个部分:
- Mark Word
- Klass Word
2.2 Mark Word
hashcode:25位的对象标识 Hash 码
age:对象分代年龄占 4 位
biased_lock:偏向锁标识,占 1 位 ,0 表示没有开始偏向锁,1 表示开启了偏向锁
thread:持有偏向锁的线程 ID,占 23 位
epoch:偏向时间戳,占 2 位
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占 30 位
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器 Monitor 的指针,占 30 位
我们可以通过lock的标识,来判断是哪一种锁的等级
- 后三位是 001 表示无锁
- 后三位是 101 表示偏向锁
- 后两位是 00 表示轻量级锁
- 后两位是 10 表示重量级锁
11 代表表示被标记为不可达,要被回收力
2.3 Monitor重量级锁
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
简单说就是:每个对象的对象头都可以设置monoitor的指针,让对象与monitor产生关联
2.4 轻量级锁
在很多的情况下,在 Java 程序运行时,同步块(synchronized 代码块)中的代码都是 不存在竞争的,不同的线程交替的执行同步块中的代码。
这种情况下,用重量级锁是没必要的。因此 JVM 引入了轻量级锁的概念。
static final Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized (obj) {
// 同步块 B
}
}
加锁的流程(了解)
- 在线程栈中创建一个 Lock Record,将其 obj 字段指向锁对象。
- 通过 CAS 指令将 Lock Record 的地址存储在对象头的 mark word 中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置 Lock Record 第一部分为 null,起到了一个重入计数器的作用。
- 如果 CAS 修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁过程(了解)
遍历线程栈,找到所有 obj 字段等于当前锁对象的 Lock Record。
如果 Lock Record 的 Mark Word 为 null,代表这是一次重入,将obj设置为 null 后 continue。
- 如果 Lock Record 的 Mark Word 不为 null,则利用CAS指令将对象头的 Mark Word 恢复成为无锁状态。如果失败则膨胀为重量级锁。
2.5 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:
- 只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头;
- 之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS;
- 以后只要不发生竞争,这个对象就归该线程所有;
static final Object obj = new Object();
public static void m1() {
synchronized (obj) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized (obj) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized (obj) {
}
}
加锁的流程(了解)
- 在线程栈中创建一个 Lock Record,将其 obj 字段指向锁对象。
- 通过 CAS 指令将 Lock Record 的线程 id 存储在对象头的 mark word 中,同时也设置偏向锁的标识为 101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁。
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置 Lock Record 第一部分为 null,起到了一个重入计数器的作用。与轻量级锁不同的时,这里不会再次进行 cas 操作,只是判断对象头中的线程 id 是否是自己,因为缺少了 cas 操作,性能相对轻量级锁更好一些。
- 解锁流程参考轻量级锁
解析的偏向锁升级流程(了解)(忽略一些细节)
示例:线程1当前拥有偏向锁对象,线程2是需要竞争到偏向锁。
- 线程 2 来竞争锁对象;
- 判断当前对象头是否是偏向锁;
- 判断拥有偏向锁的线程 1 是否还存在;
- 线程 1 不存在,直接设置偏向锁标识为 0 (线程 1 执行完毕后,不会主动去释放偏向锁);
- 使用 cas 替换偏向锁线程 ID 为线程 2,锁不升级,仍为偏向锁;
- 线程 1 仍然存在,暂停线程 1;
- 设置锁标志位为 00(变为轻量级锁),偏向锁为 0;
- 从线程 1 的空闲 lock record 中读取一条,放至线程 1 的当前 lock record 中;
- 更新 mark word,将 mark word指向线程 1 中 lock record的指针;
- 继续执行线程 1 的代码;
- 锁升级为轻量级锁;
- 线程 2 自旋来获取锁对象;
2.6 参考回答
Java 中的 synchronized 有
- 偏向锁
- 轻量级锁
- 重量级锁
三种形式,分别对应了锁
- 只被一个线程持有
- 不同线程交替持有锁
- 多线程竞争锁
三种情况。
描述 | |
---|---|
重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是 CAS 操作,保证原子性 |
偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断 mark word 中是否是自己的线程id即可,而不是开销相对较大的 CAS 命令 |
一旦锁发生了竞争,都会升级为重量级锁
锁升级就是,无锁-偏向锁-轻量级锁-重量级锁
3. 你谈一谈JMM(Java 内存模型)
JMM (Java Memory Model),Java内存模型,是 java 虚拟机规范中所定义的一种内存模型。
描述了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取变量这样的底层细节。
特点:
-
所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
-
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
-
线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。
4. CAS 你了解吗?
4.1 基本工作流程
CAS 的全称是: Compare And Swap,比较再交换,它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在 JUC 包下实现的很多类都用到了 CAS 操作
-
AbstractQueuedSynchronizer(AQS框架)
-
AtomicXXX类
例子:
我们还是基于刚才学习过的 JMM 内存模型进行说明
- 线程1与线程2都从主内存中获取变量 int a = 100,同时放到各个线程的工作内存中
三个参数,当前主内存值公共值 V、工作内存旧值 A、即将更新的值 B
当且仅当旧值 A 和内存值 V 相同时,将内存值修改为B并返回 true,否则什么都不做,并返回 false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功;
线程1操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 101 (a++)
- 线程1 拿旧值 A 的值与当前的公共值 V 进行比较,判断是否相等;
- 如果相等,则把 B 的值 101 更新到主内存中 ;
线程2操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 99 (a–)
- 线程 2 拿旧值 A 的值与当前的公共值 V 进行比较,判断是否相等;(目前不相等,因为线程 1 已更新 V 的值99)
- 不相等,则线程 2 更新失败,自旋重试…
自旋锁操作
-
因为没有加锁,所以线程不会陷入阻塞,效率较高,但特耗 CPU
-
如果竞争激烈,重试频繁发生,效率会受影响
伪代码:
class AtomicInteger {
private int V;
public void decrement() {
int A = V;
int B = A - 1;
while (CAS(V, A, B) != true) {
A = V;
B = A - 1;
}
}
}
需要不断尝试获取共享内存 V 中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功
4.2 CAS 底层实现
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,是一条指令,原子的
都是 native 修饰的方法,由系统提供的接口执行,并非 java 代码实现,一般的思路也都是自旋锁实现
伪代码:
class AtomicInteger {
private int V;
public void decrement() {
int A = V;
int B = A - 1;
while (CAS(V, A, B) != true) {
A = V;
B = A - 1;
}
}
}
在 java 中比较常见使用有很多,比如 ReentrantLock 和 Atomic 开头的线程安全类,都调用了 Unsafe 中的方法
- ReentrantLock中 的一段 CAS 代码
4.3 乐观锁和悲观锁
-
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
-
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
5. 谈一谈你对 volatile 的理解
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,代表变量不稳定,JVM 做出的优化可能会出错;
5.1 保证线程间的可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile 的变量每次修改会立即写入主内存,其他线程每次读 volatile 的变量都会读主内存到工作内存。
一个典型的例子:永不停止的循环
package com.itheima.basic;
// 可见性例子
// -Xint
public class ForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
System.out.println("modify stop to true...");
}).start();
foo();
}
static void foo() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("stopped... c:"+ i);
}
}
当执行上述代码的时候,发现 foo() 方法中的循环是结束不了的,也就说读取不到共享变量的值结束循环。
主要是因为在 JVM 虚拟机中有一个 JIT(即时编辑器)给代码做了优化。
上述代码
while (!stop) { i++; }
代码块中并没有对变量的修改,在很短的时间内,这个代码执行的次数太多了,当达到了一个阈值,JIT 就会优化此代码,如下:
while (true) { i++; }
当把代码优化成这样子以后,及时
stop
变量改变为了false
也依然停止不了循环
解决方案:
-
在程序运行的时候加入vm参数
-Xint
表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用) -
在修饰
stop
变量的时候加上volatile
,表示当前代码禁用了即时编辑器,问题就可以解决,代码如下:-
static volatile boolean stop = false;
-
5.2 禁止指令重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果;
在去获取上面的结果的时候,有可能会出现4种情况
情况一:先执行actor2获取结果—>0,0(正常)
情况二:先执行actor1中的第一行代码,然后执行actor2获取结果—>0,1(正常)
情况三:先执行actor1中所有代码,然后执行actor2获取结果—>1,1(正常)
情况四:先执行actor1中第二行代码,然后执行actor2获取结果—>1,0(发生了指令重排序,影响结果)
可以用压测工具 jcstress 去观察指令重排序,正常情况下是几乎看不到的,概率很低;
解决方案
- 在变量上添加volatile,禁止指令重排,则可以解决问题
- 写操作加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下
- 读操作加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上
其他补充
我们上面的解决方案是把 volatile 加在了 int y 这个变量上,我们能不能把它加在 int x 这个变量上呢?
下面代码使用 volatile 修饰了x 变量
这样显然是不行的,主要是因为下面两个原则:
- 写操作加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下
- 读操作加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上
所以,现在我们就可以总结一个 volatile 使用的小妙招:
- 写变量让 volatile 修饰的变量的在代码最后位置
- 读变量让 volatile 修饰的变量的在代码最开始位置
6. 什么是 AQS
全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架,它是构建锁或者其他同步组件的基础框架
6.1 AQS 与 synchronized 的区别
synchronized | AQS |
---|---|
关键字,c++ 语言实现 | java 语言实现 |
悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供了多种解决方案 |
AQS 常见的实现类
-
ReentrantLock 阻塞式锁
-
Semaphore 信号量
-
CountDownLatch 倒计时锁
6.2 工作机制
- 在 AQS 中维护了一个使用了 volatile 修饰的 state 属性来表示资源的状态,0 表示无锁,1 表示有锁
- 提供了基于先进先出的等待队列,类似于 Monitor 的 EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
如果多个线程共同去抢这个资源是如何保证原子性的呢?
在去修改 state 状态的时候,使用的 cas 自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入队列中等待
AQS是公平锁吗,还是非公平锁?
-
新的线程与队列中的线程共同来抢资源,是非公平锁
-
新的线程到队列中等待,只让队列中的 head 线程获取锁,是公平锁
比较典型的 AQS 实现类 ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源
7. ReentrantLock 可重入锁实现原理
ReentrantLock 翻译过来是可重入锁,相对于 synchronized 它具备以下特点:
-
BLOCKED的线程可中断(将因为争夺 ReentrantLock 而正在阻塞中的线程打断)
-
可以设置超时时间
-
可以设置公平锁
-
支持多个条件变量
与 synchronized 一样,都支持重入
实现原理
ReentrantLock 主要利用 CAS + AQS 队列来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁)
- 当设置为 true 时,表示公平锁,否则为非公平锁
- 公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
查看ReentrantLock源码中的构造方法:
- 提供了两个构造方法,不带参数的默认为非公平
- 如果使用带参数的构造函数,并且传的值为true,则是公平锁
其中 NonfairSync 和 FairSync 这两个类父类都是 Sync
而 Sync 的父类是 AQS,所以可以得出 ReentrantLock 底层主要实现就是基于 AQS 来实现的
工作流程(了解)
线程来抢锁后使用 cas 的方式修改 state 状态,修改状态成功为 1,则让 exclusiveOwnerThread 属性指向当前线程,获取锁成功
假如修改状态失败,则会进入双向队列中等待,head 指向双向队列头部,tail 指向双向队列尾部
当 exclusiveOwnerThread 为 null 的时候,则会唤醒在双向队列中等待的线程
公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁
8. synchronized和 jdk 提供的 Lock 有什么区别 ?
-
语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,Lock 是 Java 接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
-
功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
- Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
-
性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
9. 死锁产生的条件是什么?
死锁:一个线程需要同时获取多把锁,这时就容易发生死锁
例如:
t1 线程获得 A 对象锁,接下来想获取 B 对象的锁
t2 线程获得 B 对象锁,接下来想获取 A 对象的锁
这种线程之间获取等待对方持有的锁的释放的现象就是死锁;
示例代码:
package com.itheima.basic;
import static java.lang.Thread.sleep;
public class Deadlock {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("lock A");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("lock B");
System.out.println("操作...");
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println("lock B");
try {
sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println("lock A");
System.out.println("操作...");
}
}
}, "t2");
t1.start();
t2.start();
}
}
控制台输出结果:
此时程序并没有结束,这种现象就是死锁现象…
线程 t1 持有 A 的锁等待获取 B 锁,线程 t2 持有 B 的锁等待获取 A 的锁。
10. 如何进行死锁诊断
其实死锁的现象就是,直接卡住,如果业务中没有其他的特殊场景,一般可以直接确定是死锁了;
主要是诊断死锁出现的位置;
我们可以使用 jdk 自带的工具:jps 和 jstack
10.1 jps 查看 Java 进程
10.2 jstack 查看 Java 线程运行时栈
使用 jstack 查看线程运行的情况,下图是截图的关键信息 jstack -l 46032
其他解决工具,可视化工具
- jconsole
用于对 jvm 的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行
- VisualVM:故障处理工具
能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe 就行(jdk 1.8 之前)
10.3 处理死锁
参考这篇文章的死锁部分:【JavaEE】多线程进阶问题-锁策略and死锁,CAS操作,Synchronized原理-CSDN博客
11. ConcurrentHashMap
ConcurrentHashMap 是一种线程安全的高效Map集合
底层数据结构:
-
JDK1.7 底层采用分段的数组+链表实现
-
JDK1.8 采用的数据结构跟 HashMap的结构一样,数组+链表/红黑二叉树。
11.1 JDK1.7中 ConcurrentHashMap
- 提供了一个segment数组,在初始化 ConcurrentHashMap 的时候可以指定数组的长度,默认是 16,一旦初始化之后中间不可扩容
- 在每个 segment 中都可以挂一个 HashEntry 数组,数组里面可以存储具体的元素,HashEntry 数组是可以扩容的
- 在 HashEntry 存储的数组中存储的元素,如果发生冲突,则可以挂单向链表(jdk1.8 之前没有使用红黑树)
以此实现分段锁,默认 16 段,锁粒度相较于方法锁小;
存储流程
- 先去计算 key 的 hash 值,然后确定 segment 数组下标
- 再通过 hash 值确定 hashEntry 数组中的下标存储数据
- 在进行操作数据的之前,会先判断当前 segment 对应下标位置是否有线程进行操作,为了线程安全使用的是 ReentrantLock 进行加锁,如果获取锁是被会使用 cas 自旋锁进行尝试
11.2 JDK1.8中 ConcurrentHashMap
在JDK1.8中,放弃了 Segment 臃肿的设计,数据结构跟 HashMap 的数据结构是一样的:数组+红黑树+链表
采用 CAS + Synchronized 来保证并发安全进行实现
-
CAS 控制数组节点的添加
-
synchronized 只锁定当前链表或红黑二叉树的首节点,只要 hash 不冲突,就不会产生并发的问题 , 效率得到提升
不分段加锁,每个哈希槽一个锁,锁粒度相较于分段锁小
12. 导致并发程序出现问题的根本原因是什么
Java 并发编程需要维持三大特性
-
原子性
-
可见性
-
有序性
并发出现问题,也是因为这三大特性没能维持住;
12.1 原子性
- 一个线程在 CPU 中操作不可暂停,也不可中断
- 期间 CPU 不能调度其他的命令
- 要不执行完成,要不因为中断不执行
比如,如下代码能保证原子性吗?
以上代码会出现超卖或者是一张票卖给同一个人,执行并不是原子性的
解决方案:
-
synchronized:同步加锁
-
JUC 里面的lock:加锁
这也是线程安全出现的核心问题所在:CPU的调度是随机的。
12.2 内存可见性
内存可见性:让一个线程对共享变量的修改对另一个线程可见
比如,以下代码不能保证内存可见性
解决方案:
-
synchronized
-
volatile(推荐)
-
LOCK
12.3 有序性
指令重排序:
- 处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
还是之前的例子,如下代码:
解决方案:
- volatile