关于多线程的一些实验
写在前面
都是做的比较浅显的一些实验,很多问题来自于小林coding,很多内容其实挺八股的,但是我的记忆偶尔会带有一些内存特性,关机就忘了,所以现在想办法通过一点实验和手敲代码落一下盘。
正式内容
线程
进程和线程的区别
线程和进程的区别,这个问题在很多地方都会遇到,无论是在操作系统还是在Java多线程。
其实两者最大的区别就是是否享有独立的执行环境。
我们以Java举例,JVM的运行时内存主要包括这样五块:
- 虚拟机栈
- 堆(常量池什么的其实也在堆里)
- 元空间
- 本地方法栈(Native)
- 程序计数器
一个进程会独立的享有这全部的五个运行时环境,而线程则不是,一个进程创建一个线程,这个线程会获得自己独立的虚拟机栈、程序计数器,对于任意该进程创建的线程,其余三者都是共用的。
举个🌰:
1 | import java.util.concurrent.*; |
其次,线程是操作系统运算调度的最小单位,因为线程之间的资源共享性质,导致它的上下文切换的开销更小。同时,他们可以通过访问全局变量或者静态变量来通信。
进程的创建和销毁需要创建和销毁上述的全部资源,而进程只需要创建和销毁程序计数器和对应的运行时栈即可。
线程的创建
很多面经喜欢把这个问题总结为四类,包括继承Thread、实现Runable和FutureTask、实现Callable、使用线程池,但其实归根到底来说还是三类,因为FutureTask这个抽象类实现了RunnableFuture接口,RunnableFuture这个接口继承了Future接口和Runable接口。
1 | public class FutureTask<V> implements RunnableFuture<V> { |
1 | public interface RunnableFuture<V> extends Runnable, Future<V> { |
简单来说,创建线程有两件事情,1.确定线程要做的事情,也就是实现run或者call方法。2.启动线程
所以我们可以直接用一个类继承Thread然后重写run方法,就像这样!
1 | class Thread3 extends Thread{ |
或者就像上面那样实现Runable接口。
关于为什么要使用FutureTask这个抽象类,其实主要是希望获得线程的返回值。
就像下面这样:
1 | class Thread4 implements Callable<Integer>{ |
如果用线程池其实创建方法也很多,可以通过下面几种方法
1 | //创建大小固定的线程池 |
还有八股里面常问的start和run的区别,run其实就是调用一下你定义的线程要执行的方法,而start才是启动线程。
线程的状态包括:new、runable、blocked、waiting、timed_waiting、terminated
sleep和wait的区别
这是我决定写这篇博客的出发点,主要是我一开始竟然不知道sleep不会释放当前占用的资源,就比如说我用synchronized关键字同步住了一个资源StringBuilder。
如果我在同步块内调用了一下sleep(0),虽然线程会放弃对该时间片的占用,但是并不会释放资源。
但是如果在同步块内使用wait(),就会放弃对当前资源和时间片的占用。
做个简单的实验,就是最开始那部分的代码,我们先通过实验进行尝试。
1 | import java.util.Random; |
我在同步块里sleep了2s,如果释放了资源,一定是够第二个线程把自己的内容加进去的。
为了让主进程等待两个线程都执行完,我加了一个两步计数器CountDownLatch,每个线程执行完就减一。用被注释掉的两行get其实也可以。
但是实际结果是:
1 | hello |
显然,资源并没有得到释放
此时别的代码都不作修改,在两个线程的同步块内分别调用wait()和notifyAll()方法
1 | import java.util.Random; |
结果如下
1 | hello |
资源成功释放,第二个线程也成功写入。
如果第二个线程不notify,而主进程又等着线程1减少计数器,那么进程就会一直等待。
所以此时我们wait的时候可以加个timeout的参数
1 | sb.wait(100); |
所以很多八股其实总结的并不好,wait并不一定需要notify才能唤醒,也可以主动设定timeout,超时也会唤醒。
小小总结一下sleep和wait
sleep方法属于Thread类,是一个静态方法,作用是让当前线程进入sleep状态;而wait是一个实例方法,属于Object类,必须被一个初始化了的实力对象调用
如果处于同步块内,sleep不会释放资源,但是wait会释放资源
sleep可以不在同步块内调用,但是wait一定要在同步块内调用。也就是说,你要释放这个资源,你必须持有这个资源的锁,否则就会报错如下:
1
2
3
4
5
6
7
8
9
10
11
12
13Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.IllegalMonitorStateException: current thread is not owner
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:191)
at SleepTry.main(SleepTry.java:44)
Caused by: java.lang.IllegalMonitorStateException: current thread is not owner
at java.base/java.lang.Object.wait(Native Method)
at java.base/java.lang.Object.wait(Object.java:338)
at SleepTry$1.run(SleepTry.java:15)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:539)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
at java.base/java.lang.Thread.run(Thread.java:833)
- 唤醒机制,sleep只能等待超时唤醒,但是wait既可以超时唤醒,也可以通过被notify()或者notifyAll()唤醒
最后这里补充一下notify和notifyAll的区别,这是小林coding上写的,说的特别形象
notify:唤起一个线程,其他线程还处于waiting状态,如果这个线程结束的时候没有notify,那么其他线程只能继续等待到超时或被中断。而且notify说是随机唤醒,但是在hotspot虚拟机里是先进先出的唤醒。
notifyAll:所有线程都被唤醒,然后进入资源争夺环节,喜闻乐见的BLOCKED状态
线程状态
之前说了线程的六个状态,这里再提醒自己默写一下:
NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED
BLOCKED和WAITING其实还挺难分清楚的,我总结为下:
虽然都是阻塞在那里,但是BLOCKED是因为资源竞争导致的阻塞
WAITING是线程无限期地等待另一个线程执行特定操作,比如上面所使用的CountDownLatch,如果调用await()方法,其实是进入WAITING状态,和调用wait方法类似。
线程停止
老生常谈,就是Java官方不建议使用Thread.stop()这种方式来停止。
有很多种方法包括
使用volatile关键字来修饰一个boolean变量,线程关注到boolean变量自己内部停止
调用线程中断Thread.interrupt(),然后线程内部检测当前线程是否为中断状态或者触发可中断操作来响应中断。
可触发中断操作是指sleep或者wait等阻塞操作,如果这时候收到中断请求会直接抛中断异常的。
通过Future管理任务,Future接口是一个可以主动停止任务的接口,Future.cancel()
关闭资源
锁
volatile关键字和synchronized关键字
这两个关键字总是被拿出来说,但其实两者的作用差距还是挺大的。
volatile的作用主要体现在禁止指令重排导致的修改不可见。
这个🌰其实挺不好举的,我试着看看能不能出现。失败了,很难复现啊,因为并不知道虚拟机底层是如何指令重排和优化的。
但是volatile关键字的目的所在,就是为了让线程知道一个变量它变化了,能感知到它的变化,借由此线程之间可以相互通信。
然后说说volatile关键字的作用域,volatile关键字主要作用于变量声明上,更多的用于实例变量或静态变量,所以局部变量声明无意义。
synchronized关键字和 ReentrantLock
接下来就是synchronized关键字,其实它更应该和ReentrantLock放在一起比较才适合,所以我们把它挪到下面来
synchronized关键字主要用于声明同步,也就是给资源加锁。
与ReentrantLock相同的,synchronized也是一个可重入锁,也就是同一个线程内再次上锁也可以获得资源。
synchronized是Java提供的原子内置锁,也被称为监视器锁。
使用synchronize关键字修饰的代码块在编译的时候前后会分别加上monitorenter和monitorexit。
这个执行到monitorenter的时候会尝试获取资源,如果获取到资源就把计数器加一,执行到monitorexit的时候就把计数器减一。为0的时候代表是可获取的。
接下来是synchronized关键字的作用域,其实挺复杂的:
作用于类的实例方法上,那就是锁住了当前实例,同一时间只能有一个线程访问该方法的任何
synchronized
实例方法。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
43import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class SynchronizedTry {
public static class beBlocked{
private int a;
private int b=10;
public synchronized int getA() throws InterruptedException {
Thread.sleep(10000);
return a;
}
public synchronized int getB(){
return b;
}
}
public static void main(String[] args) throws InterruptedException {
beBlocked test = new beBlocked();
Integer a = 10;
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5,10,1000L, TimeUnit.SECONDS,new LinkedBlockingQueue<>());
threadPoolExecutor.submit(new Runnable() {
public void run() {
synchronized (a) {
try {
test.getA();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
threadPoolExecutor.submit(new Runnable() {
public void run() {
int b =test.getB();
System.out.println(b);
}
});
threadPoolExecutor.shutdown();
}
}以这段代码为例,调用getA方法sleep的那10s,另一个线程是没有办法获得到哪怕是getB方法的返回值的。
作用于静态方法,那就会锁住类对象,同一时间只能有一个线程访问该方法的任何
synchronized
静态方法。作用于代码块,也就是常用的synchronized(){}范式,括号里可以为Object或者this,也可以是Class对象
ReentrantLock相比于synchronized更为精细化。
它实现了两个接口,Lock接口和序列化接口
1 | public class ReentrantLock implements Lock, java.io.Serializable { |
同步的实现主要依赖于继承自AbstractQueuedSynchronizer类(AQS)类的Sync类
ReentrantLock是可重入锁,从名字就可以看出来,每次获取都需要相应的释放操作,锁内部维护了一个计数器来记录获取的次数。这点和synchronized关键字很像。
实例化ReentrantLock的时候可以选择是否启用公平锁。公平锁会按照请求顺序授予锁,而非公平锁则允许插队(即新来的线程可能在等待中的线程之前获得锁)。默认是非公平锁。
1 | /** |
同时ReentrantLock支持中断响应,也就是在等待锁的时候在同步块内响应打断。
同时也支持非阻塞式的获取锁,tryLock,如果不能获得锁,立刻返回,也可以传入等待时间。
1 | ReentrantLock reentrantLock = new ReentrantLock(true); |
其他的锁
synchronized和ReentrantLock都是排他锁,其实还有很多其他锁的类型。
像是ReadWriteLock,写锁是独占锁,但是读锁是共享锁。
以及一些概念性的锁,乐观锁和悲观锁。
乐观锁其实本质上就是假设资源没人用,有人用了我再重来。悲观锁就是synchronized和ReentrantLock这样的锁,一定要独占了再去操作。
自旋锁主要是靠CAS实现的。CAS全称Compare And Set。
涉及三个参数:内存位置(V)、预期原值(A)和新值(B)。CAS 的执行逻辑如下:
- 检查内存位置 V 中的值是否等于预期原值 A。
- 如果相等,则将内存位置 V 的值更新为新值 B,并返回成功。
- 如果不相等,说明有其他线程已经修改了该位置的值,则不进行任何操作,并返回失败。
这其实是乐观锁的一种实现。Java的原子类比如AtomicInteger就提供这种类型的方法:compareAndSet
1 | /** |
sychronized
sychronized的锁升级过程:
无锁->偏向锁->轻量级锁->重量级锁
偏向锁是JAVA1.6引入的,当一个线程拿到锁之后,会记录它的线程ID,如果没有竞争时,只需要比较记录的ID与自己是否一致,一致直接获得锁,不需要CAS操作。
当有锁竞争的时候,偏向锁升级为轻量级锁。这时候的锁通过CAS实现,允许自旋。
当竞争激烈的时候,轻量级锁升级为重量级锁,系统挂起线程而不是线程自旋。
AQS
全名为抽象同步队列,实现同步的重要底层之一。
主要维护一个阻塞队列和一个state。如果state为0或者为同线程(可重入锁),则可获得锁,计数器+1;
竞争失败的线程加入到阻塞队列中去,如果是公平锁,新来的线程直接加入到阻塞队列中去
非公平锁为什么比公平锁吞吐量大
因为非公平锁获取线程CAS如果获取到锁直接就拥有锁,不需要进行上下文切换。
死锁条件
- 互斥条件
- 拥有并等待
- 不可剥夺
- 资源依赖环路