You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
107 lines
11 KiB
107 lines
11 KiB
---
|
|
并发相关口水话
|
|
---
|
|
|
|
#### 目录
|
|
|
|
1. 进程和线程的区别?
|
|
2. 创建线程的方式?
|
|
3. 线程的生命周期?
|
|
4. 一些 Object、Thread 方法的理解?
|
|
1. wait、notify/notifyAll 等待通知机制
|
|
2. Thread 的 sleep、join、yield 和线程中断
|
|
5. 线程死锁
|
|
6. volatile 的实现原理
|
|
7. synchronized 的实现原理
|
|
8. Lock 的实现原理
|
|
9. 原子类的实现原理
|
|
|
|
#### 进程和线程区别?
|
|
|
|
进程是操作系统资源分配的基本单位,而线程是 CPU 调度和分配的基本单位。进程有独立的地址空间,一个进程崩溃后在保护模式下不会对其他进程产生影响,而线程只是一个进程中的不同的执行路径,所以也可以说进程是执行的程序,线程是进程内不同的执行控制流。当然,进程是执行的程序,这是一种非正式的说法,进程不只是程序代码,进程还包括当前活动,如进程堆栈和数据段等等。
|
|
|
|
操作系统内的每个进程表示,采用进程控制块 PCB,它包含很多和进程相关的信息,如进程状态、CPU 寄存器、CPU 调度信息等等。而线程也有一个 TCB 线程控制块表示,它包括线程 ID、程序计数器、堆栈等等。
|
|
|
|
#### 创建线程的方式?
|
|
|
|
创建线程有两种方式,一种是直接 new Thread 重写它的 run 方法,还有一种是实现 Runnable 接口传给 Thread。这两种方式都是没法获取任务执行结果的,如果需要获取任务执行结果,就需要使用到了 FutureTask。而且因为历史设计的原因,Thread 只接受 Runnable 而不接受 Callable,而 FutureTask 就是是 Runnable 和 Callable 的包装,本身是继承 Runnable 的,所以可以直接传给 Thread,调用其 get 方法就可以获取到执行结果,如果任务没有执行完,无参 get 就会一直阻塞,当然也可以使用 超时 get,超过一定时间就返回 null。
|
|
|
|
#### 线程的生命周期?
|
|
|
|
![](https://i.loli.net/2020/06/22/6n8CdQhjZqK2UOL.jpg)
|
|
|
|
#### 一些 Object、Thread 方法的理解?
|
|
|
|
首先就是线程的等待通知机制,等待通知机制设计到了两个函数 wait 和 notify/notifyAll,它们就是 Object 的方法,wait 函数是当一个线程调用了共享变量的 wait 方法时,该调用线程就会被阻塞挂起,直到其他线程调用了该共享变量的 notify/notifyAll 方法唤醒。需要注意的是,调用 wait 方法的线程需要先获取该对象的监视器锁,不然就会抛出 IllegalMonitorStateException,那么如何获取一个共享变量的监视器锁呢?其实就是加 synchronzied 即可。调用 wait 方法会释放当前共享变量的监视器锁,不然就会死锁了。wait 还有一个超时重载函数,如果在指定时间没有被唤醒就直接返回了,无参 wait 调用的其实就 wait(0) 方法。调用 notify 函数是随机唤醒一个等待线程,而 notifyAll 就是唤醒所有等待线程。同 wait 一致,只有获取了共享变量的监视器锁后,才可以调用其 notify/notifyAll 方法。
|
|
|
|
还有等待线程执行完成的 join 方法,以及让线程休眠的 sleep 方法,sleep 方法和 wait 方法不同的是,sleep 并不会释放锁。Object#wait、Thread#join、Thread#sleep 在阻塞期间,其他线程调用了该线程的 interrupt 方法中断线程,都会抛出 InterruptedException。
|
|
|
|
其次就是让出 CPU 执行权的 Thread#yield 方法,让线程调度器进行下一轮的线程调度,不过需要注意的是,下一轮调度的时候也有可能会再次调度到自己。yield 方法一般是用在自旋中。
|
|
|
|
最后就是线程中断了,线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接中断线程的执行,而是被中断的线程根据中断状态自行处理。现在中断包括两个函数,一个 interrupt() 方法,中断线程,还有一个是 Thread 的静态方法 interrupted 方法,前者不会清楚标志位,后者会清除标志位。
|
|
|
|
#### 线程死锁
|
|
|
|
死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一直相互等待而无法继续运行下去。
|
|
|
|
死锁产生的条件必须具备以下四个条件:互斥条件、请求并持有、不可剥脱和环路等待。想要避免死锁,只需要破坏掉至少一个构成死锁的条件即可,但是目前只有请求并持有和环路等待条件是可以被破坏的。造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁。
|
|
|
|
然后比较滑稽的是,现代操作处理死锁的办法是直接忽视。虽然看起来这似乎不是一个解决死锁问题的可行办法,但是确实大多数操作系统所采用的,代价是一个重要的考虑因素,对于许多系统,死锁很少发生(如一年一次),发生死锁就直接人工重启了。使用频繁的死锁预防、死锁避免和死锁检测与恢复相比,这种办法更便宜。
|
|
|
|
#### volatile 的实现原理
|
|
|
|
volatile 保证了共享变量的可见性和有序性。
|
|
|
|
可见性是指一个线程修改了共享变量,另一个线程可以立即感知到。有序性是指禁止编译器或处理器重排序。
|
|
|
|
volatile 是如何保证可见性的呢?
|
|
|
|
其实是 JVM 在 volatile 写的时候加一个 lock 前缀,它包含两层含义,第一个是将当前处理器缓存行的数据写回到系统内存,第二个就是这个写内存的操作会使其他 CPU 里缓存了该内存地址的数据无效。但是,就算回写到内存,如果其他处理器缓存的值还是旧的还是有问题的,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,即每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,如果内存地址被修改就会把当前处理器的缓存行设置为无效状态,当处理器对这个数据进行操作的时候,就会重新拉一份新的值。
|
|
|
|
volatile 是如何保证有序性的呢?
|
|
|
|
其实是通过内存屏障来实现的,具体有以下三条:
|
|
|
|
1. 在 volatile 写操作的前面插入一个 StoreStore 屏障,保证 volatile 写操作不会和之前的写操作重排序。
|
|
2. 在 volatile 写操作的后面插入一个 StoreLoad 屏障,保证 volatile 写操作不会和之后的读操作重排序。
|
|
3. 在 volatile 读操作的后面插入一个 LoadLoad 屏障 + LoadStore 屏障,保证 volatile 读操作不会和之后的读操作、写操作重排序。
|
|
|
|
#### synchronized 的实现原理
|
|
|
|
先说一下 synchronized 的基本使用,对于普通方法,锁是当前实例对象,对于静态方法,锁是当前类的 Class 对象,对于同步代码块,锁是括号里配置的对象。
|
|
|
|
对于锁方法,就是在编译方法的时候 ACCESS_FLAGS 加一个 ACC_SYNCHRONIZED 标识位,Access_Flags 就是访问标识位,除此之外还有常见的 public、private、static 等等。
|
|
|
|
对于锁代码块,其实就在代码块的前后增加一对 monitorenter 和 monitorexit 指令。
|
|
|
|
在 Java 1.6 时,synchronized 做了大量优化,引入了轻量级锁和偏向锁。此时锁有四种状态,分别是无锁、偏向锁、轻量级锁和重量级锁。这几个状态会随着竞争情况逐渐升级,锁可以升级但不能降级,不过锁降级确实会发生,只不过概率很小,当 JVM 进入安全点的时候,会检查是否有闲置的 Monitor,然后试图进行降级。
|
|
|
|
在讲这四种状态之前,首先要先讲一下对象头。
|
|
|
|
synchronized 用的锁的信息是存放在 Java 对象头的 Mard Word 标记字段中的,它里面保存了对象的 HashCode、分代年龄和锁标志位。锁标志位用两个 bit 表示,00 表示轻量级锁,10 表示重量级锁,01 表示偏向锁和无锁,它们两个再用一个 bit 表示是否是偏向锁。
|
|
|
|
下面就先讲一下偏向锁,为什么要有偏向锁呢?其实呢,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头里记录锁偏向的线程 ID,下次该线程再次进入只需要判断线程 ID 就可以了。
|
|
|
|
接着讲轻量级锁。当有一个线程竞争获取锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,就会进行 CAS 操作获取该锁。如果获取到了就替换线程 ID,继续保持偏向锁状态,如果获取不到就自旋一段时间再次获取,也就是自旋锁,如果在指定次数没有成功,就会膨胀为重量级锁,当前线程阻塞掉。默认次数好像是 15,当然,后面出了自适应自旋锁,会根据上次自旋的次数来设置。因为长时间的自旋会消耗 CPU,所以会有限制次数这一说。轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期都不存在长时间的竞争。
|
|
|
|
#### Lock 的实现原理
|
|
|
|
Lock 和 synchronized 相比,都是可重入的独占锁,不同的是 Lock 是显式锁,lock/unlock 需要自己手动调用加解锁,而 synchronized 是隐式锁。Lock 本身是一个接口,可以通过调用 lockInterruptibly 设置可中断锁,也可以调用其 newConditicon 获取一个 Condition,它的一个实现子类 ReentrantLock 也可以实现公平锁和非公平锁,默认是非公平锁。
|
|
|
|
但是呢,现在基本上大多数还是使用 synchronized,因为 synchronized 是 JVM 的一个内置属性,它能执行一些优化,例如锁消除、锁力度粗化等等。
|
|
|
|
#### 原子类的实现原理
|
|
|
|
原子类即是指 Java 中的 Atomic 类,比如 AtomicInteger、AtomicLong、AtomicStampedReference、AtomicReference 等。都是通过 CAS 来做的。
|
|
|
|
CAS 即比较并替换,它的通过硬件来保证操作的原子性。
|
|
|
|
在 Java 中,UnSafe 类提供了对 CAS 的简单封装,Atomic 类内部也都是使用 UnSafe 类来做的,UnSafe 类是可以直接操作内存的,一般在应用程序中是不能使用的,它是由启动类加载器加载的。UnSafe 类提供了一系列的 compareAndSwapXxx 方法,它们都是 native 方法。除此之外,UnSafe 还有一对 park/unpark 阻塞唤醒线程的方法,LockSupport 便是对它的包装。
|
|
|
|
CAS 存在的问题也比较多,但是现在基本上都已经有解决方案。
|
|
|
|
首先是 ABA 问题,解决思路就是加一个版本号,可以使用 AtomicStampedReference 来解决。
|
|
|
|
其次是循环时间长开销大,这个问题的解决可以参考 Java8 新增的 LongAdder 类。在高并发场景下,大量线程会同时去竞争更新同一个原子变量,但是由于同时只有一个线程的 CAS 操作会成功,这就造成了大量线程竞争失败后自旋继续尝试,严重损耗 CPU,这时候 LongAderr 的思路就是把一个变量分解为多个变量,让多个线程去竞争多个资源,也就是把 long 值分为一个 base 加上一个 Cell 数组,最后取值时就是 base 加上多个 Cell 的值。
|
|
|
|
最后是 CAS 的一个限制,就是只能保证一个共享变量的原子操作。解决办法就是可以把多个共享变量合成一个共享变量,比如 ThreadPoolExecutor 的 ctl 字段包含了线程池状态和 Worker 线程数量。或者可以使用 AtomicReferecne 类来保证引用对象之间的原子性,也就是把多个变量放在一个对象里进行 CAS 操作。 |