Theme NexT works best with JavaScript enabled
0%

多线程学习记录

^ _ ^

参考资料

  1. 狂神说的课程 : https://www.bilibili.com/video/BV1V4411p7EF?p=3
  2. 还是狂神说的课程 : https://www.bilibili.com/video/BV1B7411L7tE?p=2

进程和线程

程序是存储在磁盘上的静态代码段。我们平常所说的“运行程序”的意思实际上是指将磁盘中的程序代码加载到内存中,操作系统开启一个进程来执行程序。可以说,进程是运行的程序。进程运行时,操作系统会为它分配一定的内存空间用于保存运行时数据,内存空间属于一种资源,所以说进程是资源分配的基本单位。一个进程中可以有很多线程,但至少有一个线程。线程有自己的工作内存,也有和其他线程共享的进程主内存,在只有一个核的CPU中,CPU的使用时间只能轮流分给各个线程,即一次只能有一个线程使用CPU。所以说,线程是CPU调度的基本单位。

并发和并行

并发 : 多个线程操作同一资源。CPU一核的情况下,通过为不同线程分配时间片实现快速交替,模拟出多线程同时执行的效果。并发编程的本质是充分利用CPU的资源。
并行 : CPU多核情况下,不同线程可运行在不同的核中,实现真正的同时执行。

创建线程

在Java中,创建线程有三种方式,分别是:

  1. 继承Thread类,并重写它的run方法。
  2. 实现Runnable接口,并重写它的run方法。
  3. 实现Callable接口,并重写它的call方法。

线程状态

  1. start : 创建状态,已创建但未启动的线程。 –> Thread.NEW
  2. ready : 就绪状态的线程,等待CPU的使用权。
  3. run : 运行状态的线程,已获得CPU的使用权。–> Thread.RUNNABLE
  4. block : 阻塞状态的线程,可能正在等待某项资源的就绪。 –> Thread.BLOCKED || Thread.WAITING || Thread.TIMED_WATING
  5. die : 已经结束的线程。 –> Thread.TERMINATED

线程操作

  1. sleep : 线程休眠一段时间,但不释放锁。
  2. stop : 线程停止,一般使用标志位实现而不是stop或destroy函数。
  3. yield : 礼让,当前运行线程主动让出CPU使用权,从运行状态转为就绪状态。但下一个CPU的使用者是由CPU决定,也有可能仍然为礼让者。
  4. join : 插队,join的调用者将强势取得当前CPU的使用权直到该线程执行完成。这种方法会阻塞其他线程,不建议使用。

线程优先级

  • 线程的优先级用数字表示,范围从1~10。
  • 需要先设置优先级再启动线程,否则设置的优先级将无效。
  • 优先级低只意味着获得调度的概率低,并不代表优先级高的线程一定会比优先级低的线程更先执行。
  • 默认值为Thread.NORMAL_PRIORITY–5。

守护线程

线程可分为用户线程守护线程,虚拟机需要确保用户线程执行完毕而不需要等待守护线程执行完毕。常见的守护线程有后台记录日志、监控内存、垃圾回收等。java中设置守护线程的方法是Thread.setDaemon(true)

线程同步

线程同步是为了解决多个线程想访问同一个资源时,保证线程安全的一种机制。它本质上是一种等待机制+锁进制,访问对象时需要获得该对象的锁,没有获得锁且需要访问该对象的线程进入这个对象的等待池形成队列,等待前面一个线程使用完毕释放锁,下一个线程再获取锁使用资源。

不安全案例

  1. 不安全的买票
  2. 不安全的银行
  3. 不安全的集合

synchronized

同步方法
同步方法即加了synchronized修饰的方法。同步方法控制对“对象”的访问。每个对象对应一把锁,每个同步方法都必须获得调用该方法的对象的锁才能执行,否则线程会阻塞。方法一旦执行,就独占该锁,直到方法返回才释放锁。

同步块
形如synchronized(obj){}的代码块称为同步块。其中obj称为同步监视器。理论上obj可以是任何对象,但是推荐使用共享资源作为监视器。在同步方法中无需指定同步监视器,因为同步方法中默认使用对象本身(即this)或class作为同步监视器

死锁

多个线程各自占有一些资源,并且互相等待其他线程占有的资源才能运行。两个或多个线程都在等待对方释放资源,因此都处于阻塞状态时的情形被称为死锁

产生死锁的四个必要条件

  1. 资源互斥:一个资源每次只能被一个线程使用。
  2. 请求与保持:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不可剥夺:进程已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待:在若干进程之间形成一种头尾相接的循环等待资源关系。

只要想办法破坏四个必要条件中的一个或多个,就可以避免死锁的发生。

Lock

JDK5.0开始,Java提供了更强大的线程同步机制–通过显示定义同步锁对象来实现同步。ReentrantLock类实现了Lock接口,拥有与synchronized相同的并发性和内存语义,常用于实现线程安全的控制中。
使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。因此选择同步控制方式的优先级应该是:Lock > 同步代码块 > 同步方法。

Synchronized和Lock的区别

  • Synchronized 是关键字,而 Lock 是 Java 接口。
  • Synchronized 无法判断获取锁的状态,Lock 可以判断是否获得了锁。
  • Synchronized 会自动释放锁,Lock 必须手动释放。
  • 如果有两个线程A,B获取同一个资源,Synchronized 下A获得锁后如果阻塞了,B就会一直等待;而 Lock 锁下B不一定会一直等待。
  • 虽然 Synchronized 和 Lock 都是可重入锁,但 Synchronized 是不可中断的非公平锁;而 Lock 是可中断的,且是否为公平锁可以通过构造函数的传参指定,类似Reentrant(true)将创建一个公平锁,默认参数为false,即非公平锁。
  • Synchronized 适合锁少量的同步代码,Lock 适合锁大量的同步代码。

Condition

用于精准的通知和唤醒线程

可通过Lock.newConditon()得到一个Condition对象。

ReentrantLock

ReentrantLock可以设置为公平锁或非公平锁:

  • 公平锁:采用先来后到规则获取锁。
  • 非公平锁:不必遵守先来后到的规则,可以插队获取锁。默认是非公平锁。

7锁现象

  1. 2个同步方法,1个对象。
  2. 1个同步方法,1个普通方法,1个对象。
  3. 1个同步方法,2个对象。
  4. 2个静态同步方法,1个对象。
  5. 2个静态同步方法,2个对象。
  6. 1个静态同步方法,1个普通同步方法,1个对象。
  7. 1个静态同步方法,1个普通同步方法,2个对象

线程通信

Java提供的解决线程通信的方法

  1. wait : 表示线程一直等待,直到其他线程通知。其与sleep函数的不同之处在于该操作会释放锁。
  2. notify : 唤醒一个处于等待状态的线程。
  3. notifyAll : 唤醒同一个对象上处于等待状态的所有线程

以上方法均是Object方法,都只能在同步方法或同步代码块中使用

生产者消费者问题

  1. 管程法 : 通过缓冲区实现生产者和消费者之间的通信。
  2. 信号量法 : 通过标志位实现生产者和消费者之间的通信。

虚假唤醒

当场景中出现多个消费者和多个生产者时,用if判断而不是while判断而带来的紊乱。
举个栗子,假设Data类有increase和decrease两个方法,两个方法都由synchronized修饰;有一个成员变量num。写一个程序希望能够实现当num等于0时加1,当num等于1时减1的效果。

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
class Data{
private int num = 0;

public synchronized void increase(){
if(num != 0){
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num++;
System.out.println(Thread.currentThread().getName() + "执行加1操作,num = " + num);
this.notifyAll();
}

public synchronized void decrease(){
if(num == 0){
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num--;
System.out.println(Thread.currentThread().getName() + "执行减1操作,num = " + num);
this.notifyAll();
}
}

分别设置两个生产者、两个消费者线程来进行实验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FakeNotifyTest {
public static void main(String[] args){
Data data = new Data();

for(int k = 1; k <= 2; k++){
new Thread(()->{
for(int i = 0; i < 20; i++){
data.increase();
}
},"IncreaseThread" + k).start();

new Thread(()->{
for(int i = 0; i < 20; i++){
data.decrease();
}
},"DecreaseThread" + k).start();
}
}
}

然后出现了迷惑结果:

分析造成这种结果的原因是:

  1. DecreaseThread1 执行,此时因为 num=1,因此 DecreaseThread1 不会被阻塞,走到了 num– 这一步,完成后 num=0。
  2. 此时,CPU使用权交给了 DecreaseThread2,因为 num=0,所以经过 if 判断时 DecreaseThread2 调用 wait 进行阻塞。
  3. DecreaseThread2 处于阻塞状态当然不能继续执行,时间片耗尽后CPU使用权又交给了 DecreaseThread1。此时 DecreaseThread1 调用notifyAll 来唤醒所有的 wait 线程,包括 DecreaseThread2。
  4. 然后 DecreaseThread2 被唤醒,出了 if 判断,执行 num– 操作,使 num=-1

综上,要想解决这个虚假唤醒的问题,只要将原来的 if 判断 换成 while 循环判断即可。

线程池

频繁创建和销毁线程会带来性能的损耗,通过线程池的思想:提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中,可以避免频繁的创建和销毁,实现重复利用。

ExecutorService是线程池接口,常见的子类有ThreadPoolExecutor,常用方法有:

  • void executor(Runnable command) : 执行任务/命令,没有返回值。
  • Future submit(Callable task) : 执行任务,有返回值。
  • void shutdown : 关闭连接池。

Executors是线程池的工厂类,用于创建并返回不同类型的线程池。

Executors

  • newSingleThreadExecutor – 单个线程
  • newFixedThreadPool – 固定大小
  • newCachedThreadPool – 可伸缩大小,遇强则强

自定义线程池 – ThreadPoolExecutor

1
2
3
4
5
6
7
8
9
public ThreadPoolExecutor(
int corePoolSize, // 核心线程池大小
int maximumPoolSize, // 最大核心线程池大小
long keepAliveTime, // 超时时间,空闲时间超时的线程会被释放
TimeUnit unit, // 超时单位
BlockingQueue<Runnable> workQueue, // 阻塞队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler; // 拒绝策略
)

最大承载 = 阻塞队列容量 + 最大核心线程池大小

线程拒绝策略

  • AbortPolicy : 阻塞队列满了,仍有请求时,不处理这个请求,并抛出异常。
  • CallerRunsPolicy : 阻塞队列满了,仍有请求时,这个请求交给提交请求的线程处理(哪来的回哪去)。
  • DiscardPolicy : 阻塞队列满了,仍有请求时,不处理这个请求,直接丢掉该请求,不抛出异常。
  • DiscardOldestPolicy : 阻塞队列满了,仍有请求时,该请求和最早的请求进行竞争,也不会抛出异常。

最大线程如何定义

  • CPU密集型:获取CPU的核数,n核的CPU就定义最大线程数为n。CPU核数可以通过 Runtime.getRuntime().availableProcessors()进行获取。
  • IO密集型:判断程序中十分耗IO的线程,最大线程应该大于这个值。

集合类不安全

List

ArrayList为例,它是并发不安全的,如果并发修改的话会出现java.util.ConcurrentModificationException异常。

常见解决方案有:

  1. 以Vector代替ArrayList,Vector中的add方法是加了synchronized的同步方法,而ArrayList中的add方法只是普通方法。
  2. 利用Collections.synchronizedList方法对普通ArrayList进行转换。
  3. 使用CopyOnWriteArrayList

Set

HashSet为例,它是并发不安全的,如果并发修改的话会出现java.util.ConcurrentModificationException异常。(PS:HashSet的底层是使用HashMap实现)。

常见解决方案有:

  1. 利用Collections.synchronizedSet方法对普通HashSet进行转换。
  2. 使用CopyOnWriteArraySet

Map

HashSet为例,它是并发不安全的,如果并发修改的话会出现java.util.ConcurrentModificationException异常。

常见解决方案有:

  1. 利用Collections.synchronizedMap方法对普通HashMap进行转换。
  2. 使用ConcurrentHashMap

Callable

Callable是一个泛型接口,需要重写call方法,方法的返回值是泛型。

细节

  1. 有缓存。
  2. 结果可能需要等待,会阻塞。

FutureTask

  • new Thread(new FutureTask<V>(Callable)).start()
  • futureTask.get() – 可能会产生阻塞

常用辅助类

CountDownLatch

  • new CountDownLatch(num) – 初始化计数值
  • countDown() – 计数值-1
  • await() – 等待计数器归0,然后再向下执行

CyclicBarrier

  • new CyclicBarrier(num, Runnable) – 初始化计数目标值,Runnable为达到目标值后的回调函数
  • await() – 等待

Semaphore

类似停车位,可用于限流场景

  • new Semaphore(num) – 许可进入的线程数量
  • acquire() – 得到一个许可证
  • release() – 释放一个许可证

读写锁

ReadWriteLock是一个接口,拥有一个唯一的实现类ReentrantReadWriteLock

  • 独占锁:写锁,一次只能被一个线程占有。
  • 共享锁:读锁,多个线程可以同时占有。

阻塞队列

BlockingQueue是一个接口,它的实现类有LinkedBlockingQueueArrayBlockingQueue等。

BlockingQueue

API

方式 抛出异常 有返回值,不抛出异常 阻塞等待 超时等待
添加 add offer put offer(,,)
移除 remove poll take poll(,)
队首 element peek

SynchronousQueue

同步队列,是BlockingQueue接口的一个实现类。不存储元素,往队列里put一个元素后必须从里面先take出来,否则不能再put进去值

四大函数式接口

函数式接口:只有一个方法的接口。

@FunctionInterface

函数型接口 – Function

有一个输入参数,一个输出参数

断定型接口 – Predicate

有一个输入参数,输出参数只能是布尔值

消费型接口 – Consumer

有一个输入参数,没有返回值

供给型接口 – Supplier

没有输入参数,只有返回值

Stream流式计算

题目

1
2
3
4
5
6
7
题目要求:用一行代码实现以下要求
现有5个用户,按如下条件进行筛选:
1. ID 必须为偶数;
2. 年龄必须大于23岁;
3. 用户名转为大写字母;
4. 按用户名字母倒序;
5. 只输出一个用户

ForkJoin

把大任务拆分为小任务

工作窃取

维护的都是双端队列

异步回调

CompletableFuture

  • runAsync() – 无返回值
  • supplyAsync() – 有返回值
  • get()
  • whenComplete
  • exceptionally

JMM

JMM 是 Java 内存模型,它拥有的一些同步约定如下:

  1. 线程解锁前,必须把共享变量立刻刷回主内存。
  2. 线程加锁前,必须读取主内存中的最新值到工作内存中。
  3. 加锁和解锁是同一把锁。

8种内存交互操作:read、load、use、assign、write、store、lock、unlock

volatile

是Java虚拟机提供的轻量级的同步机制。

  • 保证可见性;
  • 不保证原子性;
  • 禁止指令重排。

指令重排:计算机并不是按编码顺序去执行代码。

源代码 –> 编译器优化的重排 –> 指令并行也可能重排 –> 内存系统也会重排 –> 执行

处理器在进行指令重排的时候,会考虑数据之间的依赖性。

指令重排导致乱序例子

假设a,b,c,d这四个值默认都为0;

线程A 线程B
x=a y=b
b=1 a=2

正常结果为x=0,y=0;但是由于可能指令重排为下列情况

线程A 线程B
b=1 a=2
x=a y=b

得到x=2;y=1的错误结果。

volatile避免指令重排的原理

内存屏障,CPU指令。

对一个用volatile声明过的变量,在执行它的写指令时,CPU会在指令的上下各加一条内存屏障指令,禁止上面指令和下面指令顺序交换。

单例模式

饿汉式单例

类创建时就创建单例,缺点是可能会浪费空间。

懒汉式

用一个对象获取单例时,才去创建这个单例。缺点是多线程时会可能会出现重复创建的问题。

DCL懒汉式

双重检测锁模式的懒汉式。

缺点是new操作时可能会发生指令重排。解决方法是volatile。

静态内部类

枚举类

CAS

CompareAndSet : 比较并交换

比较当前工作内存中的值和主内存中的值,如果这个值是期望的,那么执行操作。如果不是,则一直循环。

1
2
AtomicInteger atomicInteger = new AtomicInteger(initialValue);
atomicInteger.compareAndSet();
1
2
3
// expect : 期望,update : 更新
// 如果期望的值达到了,就更新;否则,就不更新
public final boolean compareAndSet(int expect, int update);

CAS 是 CPU的操作原语

Java无法操作内存,C++可以操作内存,所以Java操作内存的方法是通过native方法调用C++。其中Unsafe类相当于C++的一个后门,供Java使用。

CAS的缺点:

  • 循环会耗时。
  • 一次性只能保证一个共享变量的原子性。
  • 会产生ABA问题

ABA问题

原子引用

AtomicReference

思想:乐观锁

公平锁和非公平锁

取决于是否可被抢占

可重入锁

自旋锁

死锁排查

  1. 使用 jps -l 定位进程号
  2. 使用 jstack 进程号 找到死锁问题