共享问题
共享问题
问题概述
一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
临界区:
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
竞态条件:
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测即程序的执行结果依赖线程执行的顺序,称之为发生了竞态条件
线程安全分析
Java变量的线程安全问题
- 成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
- 局部变量是否线程安全?
- 局部变量是线程安全的
- 但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
常见的线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。它们的每个方法是原子的,但注意它们多个方法的组合不是原子的。(其中String和Integer由于不可变性保证的线程安全)
解决方案
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
- 线程封闭:使用局部变量,仅在单线程内访问数据
Synchronized
Synchronized即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
使用:
同步代码块:
1
2
3
4synchronized(对象) // 线程1获 得锁,则线程2(blocked)
{
临界区
}同步方法:
1
2
3public synchronized void test() { // 默认锁的是this对象,static方法锁的是类对象
临界区
}Monitor
Monitor被翻译为监视器或管程,每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向Monitor对象的指针。
结构图如下:
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一
个 Owner - 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入
EntryList BLOCKED - Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程
管程:
信号量机制存在的问题:编写程序困难、易出错。管程就是一种机制,让程序员写程序时不需要再关注复杂的PV操作,让写代码更轻松。类似于java的synchronized。
管程的基本特征:
局部于管程的数据只能被局部于管程的过程所访问;
一个进程只有通过调用管程内的过程才能进入管程访问共享数据;
每次仅允许一个进程在管程内执行某个内部过程。
wait/notify
用于防止条件不满足的线程一直占用锁的一种方式。(前提是必须拥有锁)
工作原理:
- Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态
- BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
- BLOCKED线程会在Owner线程释放锁时唤醒
- WAITING线程会在Owner线程调用notify或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList重新竞争
相关API:
obj.wait()
/obj.wait(n)
:让进入 object 监视器的线程到 waitSet 等待obj.notify()
:在 object 上正在 waitSet 等待的线程中挑一个唤醒obj.notifyAll()
:让 object 上正在 waitSet 等待的线程全部唤醒
尽量多使用notifyAll()而不是notify(),虽然只需要一个线程去获取锁,但是notify()会导致某些线程永远不会被叫醒。
那什么时候可以使用 notify() 呢?需要满足以下三个条件:
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
具体使用:
1 | synchronized(lock){ |
与sleep的区别:
- sleep是 Thread方法,而wait是 Object的方法
- sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起用
- sleep在睡眠的同时,不会释放对象锁的,但wait在等待的时候会释放对象锁。
锁优化
对于锁的演化过程,会经历如下阶段
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
同样使用Synchronized关键字,会首先使用轻量级锁加锁,如果加锁失败了才会使用重量级锁加锁。
加锁过程:
创建锁记录(Lock Record))对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
让锁记录中Object reference指向锁对象,并尝试用CAS替换Object的Mark Word,将Mark Word的值存入锁记录
如果CAS替换成功,对象头中存储了锁记录地址和状态 00,表示由该线程给对象加锁
如果CAS失败了,有两种情况
如果是其它线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程
如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
当退出 synchronized代码块(解锁时)如果有取值为null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
当退出 synchronized 代码块(解锁时)锁记录的值不为null,这时使用CAS将Mark Word的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
重入锁:
可重入锁是进程可以多次声明锁而不会自行阻塞的锁。
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 static final Object lock = new Object();
public static void t1(){
synchronized(lock){
// 同步块1
t2();
}
}
public static void t2(){
synchronized(lock){
// 同步块2
t3();
}
}
public static void t3(){
synchronized(lock){
// 同步块3
}
}锁膨胀:
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁
这时Thread-1加轻量级锁失败,进入锁膨胀流程
- 即为object对象申请Monitor锁,让Object指向重量级锁地址
- 然后自己进入Monitor的EntryList BLOCKED
当Thread-0退出同步块解锁时,使用CAS将Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中 BLOCKED线程
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
- 自旋的优势在于避免了线程上下文切换的消耗,但是自旋优化适用于多核cpu的环境下。
在Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。
Java 6中引入了偏向锁来做进一步优化:
- 只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有,如果另一个线程也尝试获取该偏向锁,这种情况会对偏向锁进行撤销变为轻量锁(如果使用wait/notify同样会撤销为重量锁)。
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2(偏向次数超过阈值),重偏向会重置对象的Thread ID;如果撤销偏向锁的次数达到更高的阈值,则会直接将所有偏向锁撤销为轻量锁整个类的所有对象都不再偏向。
使用-XX:+/-UseBiasedLocking 启用/禁用偏向锁
锁消除
锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
例如:
1 | public void test(){ |
锁粗化
JVM若发现前后相邻的synchronized块使用的是同一个锁对象,那么它就会把这几个synchronized块给合并为一个较大的同步块,这样做的好处在于线程在执行这些代码时,就无需频繁申请与释放锁了,从而达到申请与释放锁一次,就可以执行完全部的同步代码块,从而提升了性能。
例如:
1 | private Object object = new Object(); |
ReentrantLock
ReentrantLock是Java的一个锁类,实现了Lock接口。与synchronized不同的是ReentrantLock是基于api层面实现的,依赖于 Unsafe类的线程挂起和恢复功能。
相对于 synchronized 它具备如下特点(与其一样可以支持可重入)
可中断:
使用
lock.lockInterruptibly();
方法表示可以被其他线程调用interrupt();
所打断;如果直接使用lock()
则不可打断。可以设置超时时间:
使用
lock.tryLock([n])
方法去尝试获取锁,如果带了参数则是尝试的超时时间。可以设置为公平锁:
默认不设置公平性,如果要设置在构造函数中传入true;但是公平锁一般没有必要,会降低并发度。
支持多个条件变量:
synchronized中也有条件变量,就是我们讲原理时那个waitSet休息室,当条件不满足时进入waitSet等待,ReentrantLock的条件变量比synchronized 强大之处在于,它是支持多个条件变量的。
使用要点:
- await 前需要获得锁
- await 执行后,会释放锁,进入 conditionObject 等待
- await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
- 竞争 lock 锁成功后,从 await 后继续执行
例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69public class 条件变量 {
static ReentrantLock lock = new ReentrantLock();
// 条件变量
static Condition condition1 = lock.newCondition();
static Condition condition2 = lock.newCondition();
static volatile boolean f1 = false;
static volatile boolean f2 = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
lock.lock();
try {
while (!f1) {
try {
// 不满足条件一,进入等待
condition1.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("满足条件1");
} finally {
lock.unlock();
}
}).start();
new Thread(() -> {
lock.lock();
try {
while (!f2) {
try {
condition2.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("满足条件2");
} finally {
lock.unlock();
}
}).start();
sleep(1000);
getF1();
sleep(1000);
getF2();
}
private static void getF2() {
lock.lock();
try {
// 满足条件2唤醒他
f2 = true;
condition2.signal();
} finally {
lock.unlock();
}
}
private static void getF1() {
lock.lock();
try {
// 满足条件1唤醒他
f1 = true;
condition1.signal();
} finally {
lock.unlock();
}
}
}可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
基本语法:
1 | // 获取锁 |
CAS
CAS 全称是 compare and swap,是一种用于在多线程环境下实现同步功能的机制、是基于乐观锁的思想。
它必须是原子操作(CAS 的底层依赖Unsafe类来实现的,Unsafe中也基本上是本地方法依赖的是 lock cmpxchg
指令(这个指令由硬件保证原子性,所谓不可再分的CPU同步原语,并且就在用户态执行不会切换到内核态),在单核 CPU 和多核 CPU 下都能够保证【比较-交换】的原子性。)。
与加锁相比,不会发生上下文切换,进入阻塞即无锁并发、无阻塞并发。但是同样有可能会因为没有分配到时间片而发生上下文切换。(所以适用于线程数少,多核CPU的场景下)
CAS 的实现逻辑:
是将主内存位置处的数值与预期数值想比较,若相等,则将主内存的值替换为新值。若不相等,则不做任何操作。
正是由于CAS需要获取主内存中最新的值,所以必须借助volatile能读取到共享变量的最新值来实现【比较并交换】的效果。
在多核状态下,某个核执行到带 lock 的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的。
具体实现:
原子整数
- AtomicBoolean
- AtomicInteger
- AtomicLong
原子引用
- AtomicReference
- AtomicMarkableReference(比AtomicReference多了修改标记位,能感知是否修改)
- AtomicStampedReference(比AtomicReference多了一个版本号,能感知追踪其他线程的修改即使修改的值不变,解决ABA问题)
原子数组
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
原子更新器:
利用原子更新器,可以针对对象的某个域(Field)进行原子操作,只能配合volatile修饰的字段使用,否则会出现异常
- AtomicReferenceFieldUpdater
- AtomicIntegerFieldUpdater
- AtomicLongFieldUpdater
原子累加器:
JDK1.8新增的累加器,性能比原子整数更好。性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0累加Cell[0],而 Thread-1累加Cell[1];最后将结果汇总,这样它们在累加时操作的不同的Cell变量,因此减少了CAS重试失败,从而提高性能。但是不支持 compareAndSet() 方法。
- LongAdder
- LongAccumulator
- DoubleAdder
- DoubleAccumulator
例子:
一个取钱的函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20private AtomicInteger balance;
public void withdraw(Integer amount) {
while(true) {
// 需要不断尝试,直到成功为止
while (true) {
// 比如拿到了旧值 1000
int prev = balance.get();
// 在这个基础上 1000-10 = 990
int next = prev - amount;
/*
compareAndSet 正是做这个检查,在 set 前先比较 prev 与当前值
- 不一致了,next 作废,返回 false 表示失败
- 一致,以 next 设置为新值,返回 true 表示成功
*/
if (balance.compareAndSet(prev, next)) {
break;
}
}
}
}利用CAS实现自旋锁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import java.util.concurrent.atomic.AtomicReference;
public class SpinLock {
private AtomicReference<Thread> owner = new AtomicReference<>();
public void lock(){
Thread currentThread = Thread.currentThread();
while(!owner.compareAndSet(null, currentThread)){
// owner == null ,则compareAndSet返回true,否则为false。
//拿不到owner的线程,不断的在死循环
}
}
public void unLock(){
owner.set(null);
}
}