本文将会介绍Java多线程中的重点知识,本文内容参考了网上的资料整理,主要为了自己看着方便,方便查找。
主要来源有:
一、锁策略
锁策略是操作系统上的一个重要策略,不仅仅局限于Java。
Synchronized 最开始使用的就是乐观锁策略。当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略。
1、乐观锁&悲观锁
1.1乐观锁
- 乐观锁做事比较乐观,它假定冲突的概率很低。
- 它的工作方式是: 先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突。我们可以引入一个 “版本号” 来解决。
使用版本号,对每一次修改都记录在案,只要发生修改则版本号+1。提交的版本必须大于当前版本才能执行更新。
1.2悲观锁
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
1.3 总结
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
2、互斥锁&自旋锁
锁的最低层就是互斥锁和自旋锁,很多高级的锁都是基于它俩实现的。
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁
2.1 互斥锁
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
**对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。**当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行
综上,如果被锁住的代码执行时间很短,那么就不应该使用互斥锁,应该选择自旋锁。
2.2 自旋锁
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
一般加锁的过程,包含两个步骤:
- 查看锁的状态,如果锁是空闲的,则执行2;
- 将锁设置为当前线程持有;
**CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,**这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
自旋锁比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。
自旋锁是一种典型的轻量级锁的实现方式:
- 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁。
- 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源。(而挂起等待的时候是
不消耗 CPU 的)。
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的
3、轻量级锁&重量级锁
锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
- CPU 提供了 “原子操作指令”。
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁。
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类。
3.1 重量级锁
加锁机制重度依赖了 OS 提供了 mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
3.2 轻量级锁
加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。
- 少量的内核态用户态切换。
- 不太容易引发线程调度。
4、读写锁
读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
读写锁适用于能明确区分读操作和写操作的场景。
读写锁的工作原理是:
- 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
- 一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
所以,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
综上,读写锁非常适合读多写少的场景
读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock
类, 实现了读写
锁。
ReentrantReadWriteLock.ReadLock
类表示一个读锁。 这个对象提供了lock/unlock
方法进行
加锁解锁。ReentrantReadWriteLock.WriteLock
类表示一个写锁。 这个对象也提供了lock/unlock
方法进
行加锁解锁。
5、公平锁&非公平锁
公平锁: 遵守 “先来后到”. B 比 C 先来的。 当 A 释放锁的之后, B 就能先于 C 获取到锁。
非公平锁: 不遵守 “先来后到”。 B 和 C 都有可能获取到锁。
操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制, 锁就是非公平锁。如果要
想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序。
**注:**公平锁和非公平锁没有好坏之分, 关键还是看适用场景。Synchronized 是非公平锁。
6、可重入锁&不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入
锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括
synchronized关键字锁都是可重入的。
Linux 系统提供的 mutex 是不可重入锁。
注:Synchronized 是可重入锁。
二、CAS
CAS: 全称Compare and swap,字面意思:”比较并交换“。一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
CAS的操作是一个原子的硬件执行完成的。
1、CAS的应用
1.1 实现原子类
标准库中提供了 java.util.concurrent.atomic
包, 里面的类都是基于这种方式来实现的。
典型的就是 AtomicInteger
类。其中的 getAndIncrement
相当于 i++
操作。
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
假设两个线程同时调用 getAndIncrement
- 两个线程都读取 value 的值到 oldValue 中。 (oldValue 是一个局部变量, 在栈上。 每个线程有自己的
栈) - 线程1 先执行 CAS 操作。由于 oldValue 和 value 的值相同, 直接进行对 value 赋值。
- 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值。 因此需
要进入循环。在循环里重新读取 value 的值赋给 oldValue。 - 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作。
- 线程1 和 线程2 返回各自的 oldValue 的值即可。
1.2 实现自旋锁
自旋锁伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
2、CAS的ABA问题
2.1 ABA问题
假设存在两个线程t1和t2。有一个共享变量num。初始值为A。
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
- 先读取 num 的值, 记录到 oldNum 变量中
- 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改。但是 num 的值已经被 t2 给改了。只不过又改成 A 了。这
个时候 t1 究竟是否要更新 num 的值为 Z 呢?
t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程
示意图:
引入版本号可以解决该问题,做的每次一修改都会被版本号记录在案。
3、CAS 机制的理解
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑。
4、ABA问题怎么解决?
给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当
前版本号比之前读到的版本号大, 就认为操作失败。
三、Synchronized 原理
了解了锁之后,了解一下Synchronized关键字的原理。
1、基本特性
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
2、加锁过程
JVM的锁分为以下四种,会根据情况依次升级。
2.1 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态。
- 偏向锁不是真的加锁,只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程。
- 偏向锁本质上相当于 “延迟加锁” 。能不加锁就不加锁, 尽量来避免不必要的加锁开销。
2.2 轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)。
此处的轻量级锁就是通过 CAS 来实现。
注: 此处的自旋不会一直持续进行,一直让 CPU 空转, 比较浪费 CPU 资源。因此达到一定的时间/重试次数, 就不再自旋了。也就是所谓的 “自适应”。
2.3 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会转变为重量级锁。
此处的重量级锁就是指用到内核提供的 mutex 。
- 执行加锁操作, 先进入内核态
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒
- 经历了很长时间,这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁
3、优化操作
3.1 锁消除
通过编译器+JVM 判断锁是否可消除。如果可以, 就直接消除。
有些程序代码中用到了 synchronized关键字,但是在单线程情况下就会进行锁消除,减少没用的资源开销。
示例:
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
3.2 锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化。
使用细粒度锁, 是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁。 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁。
四、JUC(java.util.concurrent) 的常见类
1、ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
ReentrantLock 的用法:
方法 | 作用 |
---|---|
lock() | 加锁, 如果获取不到锁就死等 |
trylock(超时时间) | 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁 |
unlock() | 解锁 |
2、ReentrantLock 和 Synchronized 的区别
- Synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现);ReentrantLock 是标准
库的一个类, 在 JVM 外实现的(基于 Java 实现)。 - Synchronized 使用时不需要手动释放锁; ReentrantLock 使用时需要手动释放。 使用起来更灵活, 但
是也容易遗漏 unlock。 - Synchronized 在申请锁失败时, 会死等。 ReentrantLock 可以通过 trylock 的方式等待一段时间就放
弃。 - Synchronized 和ReentrantLock默认都是非公平锁。可以通过构造方法传入一个 true 开启。
公平锁模式。 - 更强大的唤醒机制。Synchronized 是通过 Object 的 wait / notify 实现等待-唤醒。 每次唤醒的是一
个随机等待的线程。 ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指
定的线程。
如何选择使用哪个锁?
- 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便。
- 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等。
- 如果需要使用公平锁, 使用 ReentrantLock。
3、Atomic 原子类
Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其
他线程干扰。所以,所谓原子类说简单点就是具有原子/原子操作特征的类。
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。
并发包 java.util.concurrent
的原子类都存放在java.util.concurrent.atomic
下:
根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:
基本类型:
使用原子的方式更新基本类型
类 | 说明 |
---|---|
AtomicInteger | 整型原子类 |
AtomicLong | 长整型原子类 |
AtomicBoolean | 布尔型原子类 |
数组类型:
使用原子的方式更新数组里的某个元素
类 | 说明 |
---|---|
AtomicIntegerArray | 整型数组原子类 |
AtomicLongArray | 长整型数组原子类 |
AtomicReferenceArray | 引用类型数组原子类 |
引用类型
类 | 说明 |
---|---|
AtomicReference | 引用类型原子类 |
AtomicMarkableReference | 原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 |
AtomicStampedReference | 原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。 |
对象的属性修改类型
类 | 说明 |
---|---|
AtomicIntegerFieldUpdater | 原子更新整型字段的更新器 |
AtomicLongFieldUpdater | 原子更新长整型字段的更新器 |
AtomicReferenceFieldUpdater | 原子更新引用类型里的字段 |
以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
常用方法的使用:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerTest {
public static void main(String[] args) {
// TODO Auto-generated method stub
int temvalue = 0;
AtomicInteger i = new AtomicInteger(0);
temvalue = i.getAndSet(3);
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:0; i:3
temvalue = i.getAndIncrement();
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:3; i:4
temvalue = i.getAndAdd(5);
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:4; i:9
}
}
4、信号量
信号量,本质上就是一个计数器,用来表示 “可用资源的个数”。
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用。
代码示例
- 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源。
- acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)。
- 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源。 观察程序的执行效果。
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
5、CountDownLatch
同时等待 N 个任务执行结束。
- 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成。
- 每个任务执行完毕, 都调用 latch.countDown()。 在 CountDownLatch 内部的计数器同时自减。
- 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕。 相当于计数器为 0 了。
public class Demo {
public static void main(String[] args) throws Exception {
CountDownLatch latch = new CountDownLatch(10);
Runnable r = new Runable() {
@Override
public void run() {
try {
Thread.sleep(Math.random() * 10000);
latch.countDown();
} catch (Exception e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
new Thread(r).start();
}
// 必须等到 10 人全部回来
latch.await();
System.out.println("比赛结束");
}
}
五、线程安全的集合类
原来的集合类, 大部分都不是线程安全的。
1、多线程环境使用 ArrayList
- 自己使用同步机制 (synchronized 或者 ReentrantLock)
Collections.synchronizedList(new ArrayList)
- 使用 CopyOnWriteArrayList
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争。
缺点:
- 占用内存较多。
- 新写的数据不能被第一时间读取到。
2、多线程环境使用队列
方法 | 说明 |
---|---|
ArrayBlockingQueue | 基于数组实现的阻塞队列 |
LinkedBlockingQueue | 基于链表实现的阻塞队列 |
PriorityBlockingQueue | 基于堆实现的带优先级的阻塞队列 |
TransferQueue | 最多只包含一个元素的阻塞队列 |
3、多线程环境使用哈希表
在多线程环境下使用哈希表可以使用:
Hashtable
ConcurrentHashMap
3.1 Hashtable
把关键方法加上了 synchronized 关键字
相当于直接针对 Hashtable 对象本身加锁。
- 如果多线程访问同一个 Hashtable 就会直接造成锁冲突。
- size 属性也是通过 synchronized 来控制同步, 也是比较慢的。
- 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低。
3.2 ConcurrentHashMap
相比于 Hashtable 做出了一系列的改进和优化
- 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是使用 Synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率。
- 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况。
- 优化了扩容方式: 化整为零
- 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去。
- 扩容期间, 新老数组同时存在。
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素。
- 搬完最后一个元素再把老数组删掉。
- 这个期间, 插入只往新数组加;查找需要同时查新数组和老数组。
六、死锁
1、认识死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
有名的就是哲学家就餐问题等。
产生死锁的四个必要条件:
- 互斥条件: 该资源任意一个时刻只由一个线程占用。
- 请求与保持条件: 一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件: 若干线程之间形成一种头尾相接的循环等待资源关系。
**注:**当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
2、如何预防和避免线程死锁?
2.1 如何预防死锁?
破坏死锁的产生的必要条件即可:
- 破坏请求与保持条件 : 一次性申请所有的资源。
- 破坏不剥夺条件 : 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 : 靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
示例:
破坏循环等待。最常用的一种死锁阻止技术就是锁排序。假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M)。
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁。这样就可以避免环路等待。
可能产生环路等待的代码:两个线程对于加锁的顺序没有约定, 就容易产生环路等待。
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock2) {
synchronized (lock1) {
// do something...
}
}
}
};
t2.start();
不会产生环路等待的代码:约定好先获取 lock1, 再获取 lock2 , 就不会环路等待。
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t2.start();
2.2 如何避免死锁?
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3…Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称<P1、P2、P3…Pn>序列为安全序列。
七、一些相关的问题
1. 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步。
2、为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例,
- synchronized 使用时不需要手动释放锁。 ReentrantLock 使用时需要手动释放. 使用起来更灵
活。 - synchronized 在申请锁失败时, 会死等。 ReentrantLock 可以通过 trylock 的方式等待一段时
间就放弃。 - synchronized 是非公平锁, ReentrantLock 默认是非公平锁。可以通过构造方法传入一个
true 开启公平锁模式。 - synchronized 是通过 Object 的 wait / notify 实现等待-唤醒。每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程。
3、信号量都用在哪些场景下?
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作。
4、ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了volatile 关键字。
5、 ConcurrentHashMap的锁分段技术?
Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁。
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争。
6、ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树。
7、Hashtable和HashMap、ConcurrentHashMap 之间的区别?
- HashMap: 线程不安全。key 允许为 null。
- Hashtable: 线程安全。 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null。
- ConcurrentHashMap: 线程安全。 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用CAS 机制。优化了扩容方式. key 不允许为 null。