五文搞定JUC之一
1. Java知识体系
首先需要对并发有一个体系的认识,同时需要了解并发理论和线程基础,并发关键字等,这些都是Java并发的地基。
- 关于我对于并发的一些认知:
- 之所以出现并发或者并行,就是为了高效利用计算机资源
- 但是资源被多个线程访问修改的时候,就会涉及到资源不一致的问题,所以后来出现了一系列的锁,以及锁优化(为了程序性能更加高)
- 后来Java工具提出来好多优秀的思想和基础框架,方便我们管理资源,这写优秀的代码值得我们去看,去研究…
2. Java并发—理论基础
2.1 多线程的出现是解决什么问题的?/ 或者说为什么需要多线程呢?
总所周知,CPU、内存、IO设备的速度是极大差异的,为了合理利用CPU的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU增加了缓存,以均衡与内存的速度差异; // 导致
可见性
问题 - 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与IO设备的速度差异;//导致
原子性
问题 - 编译器优化指令执行次序,使得缓存得到更加合理地利用。// 导致
有序性
问题
2.2 多线程不安全是指什么?举例说明
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。
以下代码演示了1000个线程同时对cnt执行自增操作,操作结束之后它的值有可能小于1000.
public class ThreadUnsafeExample {
private int cnt = 0;
public void add() {
cnt++;
}
public int get() {
return cnt;
}
}
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
ThreadUnsafeExample example = new ThreadUnsafeExample();
// countDownLatch 是一个多线程工具,是大佬们提供的,用于线程等待,1000个线程执行完才会
// 让main线程跑。
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();// 每一个线程执行完核心代码,将计数器-1
});
}
countDownLatch.await();// 将main线程阻塞,1000个
executorService.shutdown();
System.out.println(example.get());
}
2.3 并发出现线程不安全的本质是什么?可见性、原子性和有序性
- 可见性: CPU缓存引起
一个线程对对共享变量的修改,另外一个线程能够立刻看到。
// thread one execute follow java code
int i = 0;
i = 10;
// thread two execute folow java code;
j = i;
/**
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的
初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即
写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内
存当中i的值还是0,那么就会使得j的值为0,而不是10.这就是可见性问题,线程1对变量i修改了之后,线
程2没有立即看到线程1修改的值。
*/
- 原子性: 分时复用引起
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
看个简单例子:
int i = 1;
// 线程1执行
i += 1;
// 线程2执行
i += 1;
// 解释: 本应该是原子操作,却因为操作系统的分时复用(线程切换)给打破了,所以原子性操作就打破了,
// 不保证计算的准确性
这里需要注意的是:i += 1需要三条 CPU 指令
- 将变量 i 从内存读取到 CPU寄存器;
- 在CPU寄存器中执行 i + 1 操作;
- 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。
- 有序性: 重排序引起
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
分析:JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?
答案: 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)
4. Java是怎么解决并发问题的?3个关键字,JMM和8个Happens-Before
4.1 第一维度:核心
- JMM本质上可以理解为,Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。这些方法包括:
- volatile、synchronized和final三个关键字
- Happens-before规则
- 单一线程原则:在一个线程内,在程序前面的操作先行发生于后面的操作。
- 管程锁定原则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
- volatile变量原则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动规则:Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
- 线程加入规则:Thread 对象的结束先行发生于 join() 方法返回。
- 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
- 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
4.2 第二维度:可见性、原子性、有序性
可见性:一个线程对共享变量的修改,对其他线程可见
原子性:一个操作要么全部执行,要么不执行
有序性:java源程序到可执行二进制文件过程中,需要经过编译器优化,指令级重排,内存重排序
2.5 线程安全是不是非真即假?不是
一个类在可以被多个线程安全调用时就是线程安全的。
线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类:
* 不可变:不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
* 绝对线程安全: 不管运行时环境如何,调用者都不需要任何额外的同步措施。
* 相对线程安全:相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不 需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
* 线程兼容: 可以在调用端添加同步逻辑,兼容线程安全的代码
* 线程对立:排斥多线程使用的类库/代码
2.6 线程安全有哪些实现思路?
2.6.1 互斥同步
- synchronized
- ReentrantLock
2.6.2 非阻塞同步
互斥同步最主要的问题就是线程阻塞和所带来的性能问题,因此这种方式也叫做阻塞同步。
互斥同步是一种被悲观并发策略,无论数据是否真的出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态的转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒操作。
- CAS
- AutomicInteger
- ABA
2.6.3 无同步方案
- 栈封闭
- 线程本地存储(Thread Local Storage)
- 可重入代码(Reentrant Code)
2.7 如何理解并发和并行的区别?
- 并发是同一时刻只有一个线程在跑;多个线程进行切换,没有真正做到同时运行;
- 并行是同一时刻多个线程在多个核上运行,属于真正地同时运行。
3. 多线程基础
面试问题:
- 线程有哪几种状态?分别说明从一种状态到另一种状态转变有哪些方式?
- 通常线程有哪几种使用方式?
- 基础线程机制有哪些?
- 线程中断方式有哪些?
- 线程的互斥访问有哪些?如何比较和选择?
- 线程之间有哪些协作方式?
@LeKu_yuan 带着这些问题去学习,思考!
3.1 线程状态转换
- 新建New: 创建后尚未启动,和普通的obj一样
- 可运行 Runnable:可能正在运行,也可能正在等待CPU时间片;包含了操作系统线程状态中的 Running 和 Ready。
- 阻塞Blocking:等待获取一个排他锁,如果其线程释放了锁就会结束此状态。
- 无限期等待Waiting: 等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
进入方法 | 退出方法 |
---|---|
没有设置timeout参数的Object.wait()方法 | Object.notify()/Object.notifyAll() |
没有设置Timeout参数的Thread.sleep()方法 | 被调用的线程执行完毕 |
LockSupport.park()方法 |
- 限期等待(Timed Waiting)
无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。
睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。
阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入。
进入方法 | 退出方法 |
---|---|
Thread.sleep()方法 | 时间结束 |
设置了timeout参数的Object.wait()方法 | 时间结束/Object.notify()/Object.notifyAll() |
设置了timeout参数的Thread.join方法 | 时间结束/ 被调用的现成执行完毕 |
LockSupport.parkNanos()方法 | |
LockSupport.parkUntil()方法 |
- 死亡Terminated
可以是线程结束任务之后自己结束,或者产生了异常而结束。
3.2 线程使用方式
有三种使用线程的方法:
实现 Runnable 接口;
实现 Callable 接口;
继承 Thread 类。
注意:实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。
代码演示:
// 实现Runnable接口
public class MyRunnable implements Runnable{
public void run(){
// ....
}
}
public static void main(String []args){
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}
// 实现Callable接口
// 与Runnable相比,Callable可以有返回值,返回值通过FutureTask()进行封装。
public class MyCallable implement Callable<Integer>{
public Integer call(){
return 123;
}
}
public static void main(String [] args){
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread t = new Thread(ft);
t.start();
System.out.println(ft.get());
}
// 继承Thread很简单就不演示了
Q: 实现接口VS继承Thread:
R: 实现接口会更好一些,因为:
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 类可能只要求可执行就行,继承整个 Thread 类开销过大。
3.3 基础线程机制
3.3.1 Executor
Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。
主要有三种 Executor:
- CachedThreadPool: 一个任务创建一个线程;
- FixedThreadPool: 所有任务只能使用固定大小的线程;
- SingleThreadExecutor: 相当于大小为 1 的 FixedThreadPool。
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.execute(new MyRunnable());
}
executorService.shutdown();
}
3.3.2 Daemon
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的不分。
当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
main()属于非守护线程。
使用setDaemon()方法将一个线程设置为守护线程。
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}
3.3.3 sleep()
Thread.sleep(millisec)方法会休眠当前正在执行的线程,millisec单位为毫秒
sleep()可能会抛出InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
3.3.4 yield()
对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。
public void run() {
Thread.yield();
}
3.4 线程中断
一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。
InterruptedException
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
public class InterruptExample {
private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
interrupted()
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
public class InterruptExample {
private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
Thread end
Executor的中断操作
调用Executor的shutdown方法会等待线程执行完毕之再关闭,但是如果调用的是shutdownNow()方法,则相当于调用每个线程的interrupt方法。
public static void main(String [] args){
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(()->{
try{
Thread.sleep(2000);
System.out.println(“Thread run”);
}catch(InterruptedException e){
e.printStackTrace();
}
})
}
executorService.shutdownNow();
System.out.println(“Main run”);
如果只想中断Excutor中的一个线程,可以通过submit()方法来提交一个线程,它只会返回一个Future<?>对象,通过调用该对象的cancel(true)方法可以中断次线程。
Future<?> future = executorService.submit(()->{
// …
});
future.cancel(true);
3.5 线程互斥同步
Java提供了两种锁机制来控制多个线程对共享资源互斥访问,第一个是JVM实现的synchronized,另一个是jdk实现的ReentrantLock。
同步代码块,同步一个对象,才会争抢锁
同步普通方法,相当于锁this
同步一个类,不同的实例都会锁住
同步一个静态方法
3.6 线程之间的协作
3.6.1 join()
在线程main thread中调用另一个线程A thread的join()方法,会将当前线程main thread挂起,而不是忙等待,直到目标线程结束。
对于如下代码,虽然b线程先启动,但是因为在b线程中调用了a线程的join方法,b线程会等待a线程结束才继续执行,因为最后能够保证a线程的输出先于b线程的输出。
public class JoinExample {
private class A extends Thread {
@Override
public void run() {
System.out.println("A");
}
}
private class B extends Thread {
private A a;
B(A a) {
this.a = a;
}
@Override
public void run() {
try {
a.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("B");
}
}
public void test() {
A a = new A();
B b = new B(a);
b.start();
a.start();
}
}
public static void main(String[] args) {
JoinExample example = new JoinExample();
example.test();
}
// 输出结果:
A
B
3.6.2 wait() notify() notifyAll()
- 调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。
- 它们都属于 Object 的一部分,而不属于 Thread。
- 只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateExeception。
使用 wait() 挂起期间,线程会释放锁
。这是因为,如果没有释放锁
,那么其它线程就无法进入对象的同步方法或者同步控制块
中,那么就无法执行 notify()
或者notifyAll()
来唤醒挂起的线程,造成死锁
。
插播重要知识:
- 内核空间是操作系统中的一块特殊的内存区域,用于存储操作系统的核心代码和数据结构。它是操作系统运行的核心部分,提供了各种系统调用、中断处理、设备驱动程序等功能。
- 在现代操作系统中,内存空间通常被划分为两个部分:用户空间和内核空间。用户空间用于运行用户程序,而内核空间用于运行操作系统内核。
- 内核空间拥有更高的特权级别和更广泛的访问权限,可以直接访问硬件设备、执行特权指令,并管理系统资源。相比之下,用户空间的程序只能通过系统调用接口向内核发出请求来访问内核空间的功能和资源。
- 内核空间通常受到严格的保护,用户程序无法直接访问或修改内核空间的内容。这种隔离保护机制有助于提高系统的稳定性和安全性,防止用户程序对系统造成不可预测的危害。
- 线程挂起之后,就会将线程暂停,并将线程上下文(寄存器信息、线程执行状态、栈信息)保存到内存中,以便在线程被唤醒后能够正确地恢复执行。
- 线程对线程栈空间、程序计数器(程序运行寄存器)等私有;对方法区(元空间)、JVM堆共享。
牛刀小试一下
public class WaitNotifyExample {
public synchronized void before() {
System.out.println("before");
notifyAll();
}
public synchronized void after() {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("after");
}
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
WaitNotifyExample example = new WaitNotifyExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
before
after
wait()和sleep()区别
- wait()是Object的方法,而sleep()是Thread的静态方法;
wait方法和sleep方法都用于线程的控制,但它们的功能和使用场景有所不同。wait方法用于线程间的协作和同步,需要在同步块中调用,并释放锁,等待其他线程唤醒。而sleep方法用于线程的暂停和定时任务,可以在任何地方调用,不需要释放锁。
- wait() 会释放锁,sleep() 不会。
3.6.3 await() signal() signalAll()
java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。使用 Lock 来获取一个 Condition 对象。
public class AwaitSignalExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
public void before() {
lock.lock();
try {
System.out.println("before");
condition.signalAll();
} finally {
lock.unlock();
}
}
public void after() {
lock.lock();
try {
condition.await();
System.out.println("after");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
// main测试
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
AwaitSignalExample example = new AwaitSignalExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
before
after
4. Java中所有的锁
Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。
Java中往往是按照是否含有某一个特性定义锁,我们通常将锁进行归类,再使用对比的方式进行介绍。
4.1 乐观锁vs悲观锁
乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。
- 先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
- 而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
- 乐观锁在Java中是通过使用无锁编程来实现,最常采用的是
CAS算法
,Java原子类中的递增操作就通过CAS自旋
实现的。
根据从上面的概念描述我们可以发现:
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
光说概念有些抽象,我们来看下乐观锁和悲观锁的调用方式示例:
// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized
public synchronized void testMethod() {
// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
lock.lock();
// 操作同步资源
lock.unlock();
}
// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个
// AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1
通过调用方式示例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?具体可以参看JUC原子类: CAS, Unsafe和原子类详解。
4.2. 自旋锁 VS 适应性自旋锁
在介绍自旋锁前,我们需要介绍一些前提知识来帮助大家明白自旋锁的概念。
- 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
- 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
- 而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
4.3 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态之前还需要介绍一些额外的知识。
总结而言: 偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
4.4 公平锁 VS 非公平锁
- 公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
- 非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。
但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。如下图所示:
4.5 可重入锁 VS 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1执行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2执行...");
}
}
如上代码,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?我们通过图示和源码来分别解析一下。
还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。
但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
4.6 独享锁(排他锁) VS 共享锁
独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
其实Java本身已经对锁本身进行了良好的封装,降低了研发同学在平时工作中的使用难度。但是研发同学也需要熟悉锁的底层原理,不同场景下选择最适合的锁。而且源码中的思路都是非常好的思路,也是值得大家去学习和借鉴的。