前言
本篇记录多线程与并发的相关知识点,周一会带来多线程的代码实践文章
并发基础
多线程的出现是要解决什么问题的?导致了什么问题?
- CPU 增加了缓存,以均衡与内存的速度差异---导致可见性问题
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异---导致原子性问题
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用---导致有序性问题
线程不安全是指什么? 本质是什么
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的
- 可见性(CPU缓存引起) 即一个线程对共享变量的修改,另外一个线程能够立刻看到
- 原子性(分时复用引起) 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 有序性(重排序引起) 即程序执行的顺序按照代码的先后顺序执行
Java是怎么解决并发问题的?
3个关键字(volatile、synchronized 和 final),JMM和8个Happens-Before规则
线程安全有哪些实现思路?
阻塞同步:加锁,sychronized和ReentrantLock
非阻塞同步:比较并交换(Compare-and-Swap,CAS)--内存地址V,旧的预期值A,要修改的新值B,V=A时替换为B
无同步方案:
局部变量:因为局部变量存储在虚拟机栈中,属于线程私有的
线程本地存储:ThreadLocal 类来实现线程本地存储功能
如何理解并发和并行、线程与进程的区别?同步和互斥的区别?
并发:一段时间内 并行:同一时间
进程是操作系统分配资源的最小单元,线程是操作系统调度的最小单元。一个程序至少有一个进程,一个进程至少有一个线程。
互斥:一个公共资源同一时刻只能被一个进程或线程使用
同步:多个进程或线程按照规定的顺序使用一个公共资源
总结:互斥是特殊的同步,同步是复杂的互斥
总结
新时代机器上的优化手段带来了对应的问题,CPU缓存带来了可见性问题、操作系统分时复用CPU带来了原子性问题、编译软件指令重排序带来了有序性问题。为了解决这些问题,JVM带来了3个关键字synchronized、volatile、final以及JMM来规范线程如何操作内存。作为开发人员可以从三方面手动解决线程不安全问题,一是悲观锁synchronized和reentrentlock,二是乐观锁CAS,三是无锁用局部变量和ThreadLocal,这两种无锁方案属于空间换安全
线程基础
线程有哪几种状态?
新建(NEW):就是刚使用 new 方法,new 出来的线程;
就绪(RUNNABLE):就是调用的线程的 start()方法后,这时候线程处于等待 CPU 分配资源阶段,谁先抢的 CPU 资源,谁开始执行;
运行(RUNNING):当就绪的线程被调度并获得 CPU 资源时,便进入运行状态,run 方法定义了线程的操作和功能;
阻塞(BLOCKED):在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,等待阻塞:wait(),同步阻塞:加锁,其他阻塞:join()和sleep()
销毁(TERMINATED):如果线程正常执行完毕后或线程被提前强制性的终止或出现异常导致结束,那么线程就要被销毁,释放资源;
通常线程有哪几种使用方式?
- 实现 Runnable 接口;
- 实现 Callable 接口;
- 继承 Thread 类。
runnable无返回值, callable有返回值
public class MyRunnable implements Runnable {public void run() {}}
public class MyCallable implements Callable<Integer> {public Integer call() {return 123;}
}
public class MyThread extends Thread {
public void run() {
}
}
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
//Callable
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
MyThread mt = new MyThread();
mt.start();
}
为什么我们调用start() 方法时会执行run() 方法,为什么我们不能直接调用 run()方法?
new ⼀个 Thread,线程进⼊了新建状态。调⽤ start() ⽅法,会启动⼀个线程并使线程进⼊了就绪状态,当分配到时间⽚后就可以开始运⾏了。 start() 会执⾏线程的相应准备⼯作,然后⾃动执⾏ run() ⽅法的内容,这是真正的多线程⼯作。 但是,直接执⾏ run() ⽅法,会把 run()⽅法当成⼀个 main 线程下的普通⽅法去执⾏,并不会在某个线程中执⾏它,所以这并不是多线程⼯作。
总结: 调⽤ start() ⽅法⽅可启动线程并使线程进⼊就绪状态,直接执⾏ run() ⽅法的话不会以多线程的⽅式执⾏
基础线程机制有哪些?
- Executor---Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。主要有三种 Executor:CachedThreadPool: 一个任务创建一个线程;FixedThreadPool: 所有任务只能使用固定大小的线程;SingleThreadExecutor: 相当于大小为 1 的 FixedThreadPool。
- Daemon---守护线程是程序运行时在后台提供服务的线
- sleep()---Thread.sleep(millisec) 方法会休眠当前正在执行的线程
- yield()---建议切换具有相同优先级的其他线程运行,可能下一个运行的还是当前线程
线程的中断方式有哪些?
- InterruptedException 报错从而中断
- interrupted() 设置中断标记位,程序通过判断主动中断
- 调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法
线程之间有哪些协作方式?
- join()--b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行
- wait() notify() notifyAll()--调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程
- await() signal() signalAll()--java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。
现在有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行?
join,在线程A运行中,线程B调用join,A就要等B执行完,在A代码块B调用join就让A等着
/**
* 现在有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行?
*/
public class JoinDemo {
public static void main(String[] args) {
//初始化线程t1,由于后续有匿名内部类调用这个对象,需要用final修饰
final Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t1 is running");
}
});
//初始化线程t2,由于后续有匿名内部类调用这个对象,需要用final修饰
final Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
//t1调用join方法,t2会等待t1运行完之后才会开始执行后续代码
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("t2 is running");
}
}
});
//初始化线程t3
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
try {
//t2调用join方法,t3会等待t2运行完之后才会开始执行后续代码
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("t3 is running");
}
}
});
//依次启动3个线程
t1.start();
t2.start();
t3.start();
}
}
Java 怎么获取多线程的返回值?
- 主线程等待。
- 使用 Thread 的 join 阻塞当前线程等待。
- 实现 Callable 接口(通过 FutureTask 或线程池的 Future)。
死锁
产⽣死锁必须具备以下四个条件:
- 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
- 请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后才释放资源。
- 循环等待条件:若⼲进程之间形成⼀种头尾相接的循环等待资源关系
避免死锁
- 破坏互斥条件 :这个条件我们没有办法破坏,因为我们⽤锁本来就是想让他们互斥的(临界资源需要互斥访问)。
- 破坏请求与保持条件 :⼀次性申请所有的资源。
- 破坏不剥夺条件 :占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源,释放资源则反序释放。破坏循环等待条件
总结
线程三种基础创建方式Thread、runable、callable,进阶后有Future、ThreadPoolExecutor、ComplatableFuture,常用的是后两个。线程协作方式,基础的有join()、wait()notify()notifyAll()、await()signal()signalAll,进阶的有JDK5开始提供的Semaphore(信号量)、CyclicBarrier、CountDownLatch以及JDK8的ComplableFuture。死锁四个必要条件是请求与保持、不可剥夺、循环等待、互斥。
Synchronized
Synchronized可以作用在哪里? 分别通过对象锁和类锁进行举例。
修饰实例方法
所谓的实例对象锁就是用 synchronized 修饰实例对象中的实例方法,注意是实例方法不包括静态方法
修饰静态方法
当 synchronized 作用于静态方法时,其锁就是当前类的 class 对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过 class 对象锁可以控制静态成员的并发操作。需要注意的是如果一个线程 A 调用一个实例对象的非 static synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的 class 对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,二者的锁并不一样,所以不冲突。
修饰代码块
在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方法对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。我们可以使用如下几种对象来作为锁的对象:成员锁、实例对象锁、当前类的 class 对象锁
Synchronized本质上是通过什么保证线程安全的?
分三个方面回答:加锁和释放锁的原理,可重入原理,保证可见性原理。
一句话总结:
通过monitor对象来加锁和解锁
用计数器来保证可重入
happenbefore中的监视器锁原则,一个unlock操作先于对同一个锁的lock
加锁或释放锁:
当修饰方法时,编译器会生成 ACC_SYNCHRONIZED 关键字用来标识
当修饰代码块时,会依赖monitorenter和monitorexit指令
在内存中,对象一般由三部分组成,分别是对象头、对象实际数据和对齐填充。对象头Mark Word会记录对象关于锁的信息
每个对象同一时间仅关联一个monitor,也就是锁,这个锁同一时间只能被一个线程获取。monitor对象中存储着当前持有锁的线程以及等待锁的线程队列。monitor对象就会把当前进入线程的Id进行存储,设置Mark Word的monitor对象地址,并把阻塞的线程存储到monitor的等待线程队列中
当尝试获取锁时,调用monitorenter指令,发生三种情况,当前锁计数器为0,这个线程+1占用这把锁,如果这个线程已经获取了这把锁,就是重入,锁计数器+1,如果当前线程没有没有获取该锁,并且锁计数器>0,说明被占用,当前线程进入等待。
释放锁就是锁计数器-1,当为0时释放
可重入原理:加锁次数计数器支持
可重入性:指同一个线程外层函数获取到锁之后,内层函数可以直接使用该锁,避免死锁
(如果不可重入,假设method1拿到锁之后,在method1中又调用了method2,如果method2没办法使用method1拿到的锁,那method2将一直等待,但是method1由于未执行完毕,又无法释放锁,就导致了死锁,可重入正好避免这这种情况)
Synchronized的happens-before规则,即监视器锁规则
一个unlock操作先行发生于后面对同一个锁的lock操作
Synchronized有什么样的缺陷? Java Lock是怎么弥补这些缺陷的。
效率低、不够灵活--加了锁之后,只有代码运行完毕或者异常中断才会释放锁
无法知道是否获取锁--Lock通过lock()和unlock()两个方法灵活控制加锁和解锁时机,同时可以通过tryLock()尝试获取锁,并且可以设置超时还可以加条件参数Condition
Synchronized和Lock的对比,和选择?
除非需要使用Lock 的高级功能,否则优先使用 synchronized。1.6之后的Java对synchronized做了例如轻量级锁之类的优化,效率上不输lock,相对而言代码量也是最少的,避免出错。
- ReentrantLock实现了Lock接口。synchronized是JVM关键字
- ReentrantLock需要手动指定锁范围。synchronized 支持同步块、同步方法
- 都具有可重入性
- 默认都是非公平锁。但 ReentrantLock 还支持公平模式,但性能会急剧下降
- ReentrantLock 需要显示的获取锁、释放锁
- ReentrantLock 支持多种方式获取锁。
-
- lock():阻塞模式来获取锁
- lockInterruptibly:阻塞式获取锁,支持中断
- tryLock():非阻塞模式尝试获取锁
- tryLock(long timeout, TimeUnit unit):同上,支持时间设置
- ReentrantLock 可以同时绑定多个Condition条件对象。
Synchronized杂项问题
Synchronized在使用时有何注意事项?
- 锁对象不能为空,因为锁的信息都保存在对象头里
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 避免死锁
- 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent(JUC)包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键字,因为代码量少,避免出错
Synchronized修饰的方法在抛出异常时,会释放锁吗?
synchronized在方法抛出异常的时候会自动解锁
对于显式锁, 如ReentrantLock,在发生异常的时候,必须要手动释放锁。
如果执行的代码段有可能发生异常,我们通常要这样处理, 需要在finally里面释放资源
多个线程等待同一个synchronized锁的时候,JVM如何选择下一个获取锁的线程?
synchronized锁为非公平锁,随机获取
我想更加灵活地控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
使用Lock,提供了lock(),unlock(),try lock()方法
什么是锁的升级和降级? 什么是JVM里的偏向锁、轻量级锁、重量级锁?
一句话总结
锁升级,指synchronized从无锁,偏向锁,轻量级锁,重量级锁升级的过程,当同一个线程多次获取同一个锁时叫做偏向锁,当多个线程轮询获取同一个锁膨胀为轻量级锁,当多个线程竞争同一个锁时转为重量级锁
锁升级指无锁到偏向锁到轻量级锁到重量级锁的过程,没有降级
偏向锁是为了减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁频繁CAS带来的性能消耗。申请锁的线程拿到锁后,对象头里mark word记录线程名及锁状态为偏向锁,如果下一个申请锁的还是这个线程,那就直接拿到偏向锁,否则判断发生竞争,膨胀为轻量级锁
轻量级锁指申请锁时, 线程通过CAS将对象头的mark word替换为指向栈帧中锁记录的指针,如果成功获得锁,失败则尝试自旋一定次数获取,如果两个以上线程竞争锁,膨胀为重量级锁
重量级锁,调用操作系统的互斥锁
JDK中对Synchronized有何优化?
一句话总结
锁粗化 for循环内的锁移到外部,两个同步代码块合并
锁消除 jvm会将无需加锁的地方的程序员加的锁给去掉
轻量级锁 CAS自旋获取锁
偏向锁 同一线程多次获取同一锁无需CAS
适应性自旋
- 锁粗化:也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
- 锁消除:通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
- 轻量级锁:这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
- 偏向锁:是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
- 适应性自旋:当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。
synchronized 的内部原理
一句话总结
监视器锁,使用monitorenter和monitorexit分别进行锁计数器的增减,并在对象头记录线程,线程获得锁,monitorenter锁计数器+1,表示加锁,并且重入时再+1,解锁的时候锁计数器减到0表示解锁,并将对象头线程设置为null
java提供的原子性内置锁,也被称为监视器锁。使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,依赖操作系统底层互斥锁实现。实现原子性操作和解决共享变量的内存可⻅性问题。内部处理过程(内部有两个队列waitSet和entryList。):
- 当多个线程进入同步代码块时,首先进入entryList
- 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
- 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
- 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null
Synchronized 核心组件
- Wait Set:哪些调用 wait 方法被阻塞的线程被放置在这里;
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
- Owner:当前已经获取到所资源的线程被称为 Owner;
- !Owner:当前释放锁的线程
总结
synchronized这个关键字其实挺好用的,经典的双重否定检查单例模式用到了,我在做一些多线程操作的时候也会用到,比如给ArrayList加锁。锁的时候锁两种,类对象和实例对象,作用范围类、对象、代码块、方法都可以,相对Lock来说代码量骤减,这是优势所在。synchronized内部使用监视器锁对对象头进行锁标识,通过计数器来实现可重入性。synchronized之前被诟病重量级是因为调用了操作系统的互斥锁,但是JVM进行了很多优化,比如锁升级、锁粗化、锁消除等。锁升级就是无锁、偏向锁、轻量级锁、重量级锁步步升级的过程。
volatile
volatile关键字的作用是什么?
- 防重排序
- 实现可见性
- 保证单次读/写原子性
volatile能保证原子性吗?
不能保证完全的原子性,只能保证单次读写的原子性
之前32位机器上共享的long和double变量的为什么要用volatile? 现在64位机器上是否也要设置呢?
因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。
i++为什么不能保证原子性?
i++其实是一个复合操作,包括三步骤:读取i的值-->对i加1-->将i的值写回内存。可以通过AtomicInteger或者Synchronized来保证+1操作的原子性
volatile是如何实现可见性的?
内存屏障这个CPU指令,禁止重排序
如果对volatile的变量进行写操作,JVM会向处理器发送一条lock前缀的指令,将当前处理器缓存行的数据写回到系统内存,这个操作会使其他CPU中的缓存无效,只能重新从系统内存中获取,从而保证了读取的都是最新的数据
volatile是如何实现有序性的?
- happens-before规则,在此规则下,volatile声明的变量写操作发生于读之前
- JMM提供了内存屏障阻止JVM的指令重排序
说下volatile的应用场景?
例如单例模式--双重否定检查判断、volatile修饰类成员变量、私有构造方法、类成员变量加锁
class Singleton {
private volatile static Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) {
syschronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
总结
volatile同样用的非常频繁,单例和AtomicXXX都有他的身影。核心作用就三个,防重排序、实现可见性、保证单次读/写的原子性。从原理出发,JMM对volatile关键字修饰的变量会有特殊优化,会插入内存屏障,规范读和写的顺序。
final
所有的final修饰的字段都是编译期常量吗?
//编译期常量
final int i = 1;
//非编译期常量
Random r = new Random();
final int k = r.nextInt();
不是所有的final修饰的字段都是编译期常量,只是值在被初始化后无法被更改
如何理解private所修饰的方法是隐式的final?
private所修饰的方法无法继承,和finial达成的效果是一样的
说说final类型的类如何拓展? 比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
新建一个类,定义String类型的变量,新建方法调用String里的方法即支持老方法,新建扩展方法例如新的toMyString()
final方法可以被重载/重写吗?
可以重载,无法重写
重写是存在于继承关系中,子类重写父类的方法,同名同参,两个限制,访问权限大于父类方法,返回类型是父类方法返回类型或其子类
重载是存在于同一个类中,指一个方法与已经存在的方法名称上相同,但是参数类型、个数、顺序至少有一个不同。应该注意的是,返回值不同,其它都相同不算是重载
final 在 Java 中有什么作用?
final作为Java中的关键字可以用于三个地方。用于修饰类、类属性和类方法。
特征:凡是引用final关键字的地方皆不可修改!
(1)修饰类:表示该类不能被继承;
(2)修饰方法:表示方法不能被重写;JVM会尝试将其内联,以提高运行效率
(3)修饰变量:表示变量只能一次赋值以后值不能被修改(常量)。在编译阶段会存入常量池中.修饰的变量不可以被改变.如果修饰引用,那么表示引用不可变,引用指向的内容可变.
说说final域重排序规则?
基本数据类型:
- final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
- final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
引用数据类型:
- 额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序
说说final的原理?
写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障
使用 final 的限制条件和局限性?
当声明一个 final 成员时,必须在构造函数退出前设置它的值。要么是声明类变量时赋值,要么是构造方法里赋值。
总结
final这个关键字很有意思,用的地方很多,但是很少有人说出有啥好处来。final可以修饰类、方法、变量,类表示不可继承、方法表示不能重写、变量表示不能修改并且会加入常量池。final修饰就一个目的,不可变,同时也会带来好处,就可以做缓存提高加载速度。原理的话,其实还是内存屏障。