基础知识

线程的状态

线程的生命周期分为五种状态:

  • 新生 NEW
  • 运行 RUNNABLE
  • 阻塞 BLOCKED
  • 等待 WAITING
  • 超时等待TIME_WAITING
  • 终止 TERMINATED

在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态

为什么 JVM 没有区分这两种状态呢?

现在的时分多任务操作系统架构通常都是用所谓的“时间分片”方式进行抢占式轮转调度。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。

wait/sleep

区别

  • sleep()** 方法没有释放锁,而 wait() 方法释放了锁** 。这是因为sleep()Thread 类的静态本地方法,不会操作对象锁,而wait() 则是 Object 类的本地方法,让获得对象锁的线程实现等待

  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。

因此,wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停当前线程的执行。

为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入等待状态,自然是要操作对应的对象而非当前的线程。

Synchronized

公平锁: 十分公平,必须先来后到;

非公平锁: 十分不公平,可以插队;**(默认)**

Lock锁

lock三部曲

1
2
3
4
5
1. Lock lock=new ReentrantLock();

2. lock.lock() 加锁

3. finally=> 解锁:lock.unlock();

Synchronized锁 与Lock锁 的区别

  • Synchronized 无法判断获取锁的状态,Lock可以判断
  • Synchronized 会自动释放锁,lock必须要手动加锁和手动释放锁!可能会遇到死锁
  • Synchronized 线程1(获得锁->阻塞)、线程2(等待);lock就不一定会一直等待下去,lock会有一个trylock去尝试获取锁,不会造成长久的等待。
  • Synchronized 是可重入锁,不可以中断的,非公平的;Lock是可重入的,可以判断锁,可以自己设置公平锁和非公平锁;
  • Synchronized 适合锁少量的代码同步问题,Lock适合锁大量的同步代码;

Callable接口

实现类:FutureTask,可以获取到线程执行完毕的结果

CallableRunable的区别:

  • Callable可以有返回值
  • Callable可以抛出异常,而Runnable不能抛出被检查的异常
  • 启动方法不同

线程安全类集合

List类

  • Vector

  • Collections.synchronizedList()

  • CopyOnWriteArrayList

    适用于读多写少的场景

    核心思想是:如果有多个调用者同时要求相同的资源,他们会共同获取相同的指针指向相同的资源,直到某个调用者视图修改资源内容时,系统才会真正复制一份专用副本给该调用者,而其他调用者所见到的最初的资源仍然保持不变。

    读的时候不需要加锁,如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的CopyOnWriteArrayList。

**CopyOnWriteArrayListVector**区别?

  • **Vector底层是使用synchronized**关键字来实现的,效率低下
  • **CopyOnWriteArrayList**使用的是Lock锁,更加高效

Set类

  • Collections.synchronizedSet()
  • CopyOnWriteArraySet

Map类

  • Collections.synchronizedMap()
  • ConcurrentHashMap
  • HashTable

线程通信

生产者和消费者问题

这是一个经典的线程通信问题。两组线程共享一个缓冲区。生产者将数据放入缓冲区,消费者将数据从缓冲区取出

demo如下:

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
43
44
45
46
47
class ProviderConsumer{
private int num = 0;

// 判断等待、业务、通知
public synchronized void increment() throws InterruptedException {
if (num > 0) {
this.wait();
}
num++;
System.out.println(Thread.currentThread().getName() + " increment num " + num);
this.notifyAll();
}

public synchronized void decrement() throws InterruptedException {
if (num == 0) {
this.wait();
}
num--;
System.out.println(Thread.currentThread().getName() + " decrement num " + num);
this.notifyAll();
}
}

public class Main {
public static void main(String[] args) {
ProviderConsumer providerConsumer = new ProviderConsumer();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
providerConsumer.increment();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}, "A").start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
providerConsumer.decrement();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}, "B").start();
}
}

虚假唤醒问题

当一定的条件触发时会唤醒很多在阻塞态的线程,但只有部分的线程唤醒是有用的,其余线程的唤醒是多余的。

https://blog.csdn.net/weixin_45668482/article/details/117373700?ydreferer=aHR0cHM6Ly9jbi5iaW5nLmNvbS8%3D

解决虚假唤醒问题:应该将唤醒放在循环中,不满足条件需要继续等待

换句话说:将if替换成while。当使用notifyAll()时,所有的线程都将被唤醒,如果使用的是if,不会再次进行条件判断,因此被唤醒的可能是消费者,也可能是生产者。而使用while的时候,会再次进行等待判断,从而避免虚假唤醒问题。

Lock + Condition实现

Lock锁的Condition可以精准通知唤醒的线程,从而控制多个线程的执行顺序

demo:多个线程轮流输出A B C

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
43
44
45
46
47
48
49
50
51
52
53
class TestCondition {
private Lock lock = new ReentrantLock();
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();
private Condition condition3 = lock.newCondition();
private int num = 1; // 1A 2B 3C

public void printA() {
lock.lock();
try {
// 业务代码 判断 -> 执行 -> 通知
while (num != 1) {
condition1.await();
}
System.out.println(Thread.currentThread().getName() + "==> AAAA" );
num = 2;
condition2.signal();
}catch (Exception e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
public void printB() {
lock.lock();
try {
while (num != 2) {
condition2.await();
}
System.out.println(Thread.currentThread().getName() + "==> BBBB" );
num = 3;
condition3.signal();
}catch (Exception e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
public void printC() {
lock.lock();
try {
while (num != 3) {
condition3.await();
}
System.out.println(Thread.currentThread().getName() + "==> CCCC" );
num = 1;
condition1.signal();
}catch (Exception e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}

BlockingQueue实现生产者消费者问题

BlockingQueue是Java自带的阻塞队列,内部的原理也是使用了ReentrantLock + Condition实现

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
public static void main(String[] args) {
BlockingQueue<Object> queue = new ArrayBlockingQueue<>(10);

Runnable producer = () -> {
while (true) {
try {
queue.put(new Object());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
new Thread(producer).start();
new Thread(producer).start();

Runnable consumer = () -> {
while (true) {
try {
queue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
new Thread(consumer).start();
new Thread(consumer).start();
}

Java中的线程协作

CountDownLatch

倒计数锁存器,可以用作一个简单的开/关锁存器,或者门:所有线程调用await()在门口等待,直到被调用countDown()的线程打开。

CountDownLatch一个有用的属性是,它不要求调用countDown线程等待计数到达零之前相互等待,它只是阻止任何线程通过await,直到所有线程可以通过。

常用方法:

  • **countDown()**减一操作;
  • await() 等待计数器归零

CyclickBarrier

循环屏障,它允许一组线程全部等待彼此达到共同屏障点的同步辅助。循环阻塞在涉及固定大小的线程方的程序中很有用,这些线程必须偶尔等待彼此。屏障被称为循环,因为它可以在等待的线程被释放之后重新使用。

常用方法包括:

  • await(): 调用该方法的线程到达屏障点,并等待其他线程到达。如果是最后一个到达的线程,将执行可选的任务。
  • await(long timeout, TimeUnit unit): 调用该方法的线程到达屏障点,并等待其他线程到达,但最多等待指定的时间。
  • getParties(): 返回需要到达屏障点的总线程数。
  • isBroken(): 检查屏障是否被破坏(是否有线程等待超时)。

**CyclicBarrier ****与 ****CountDownLatch **区别

  • CountDownLatch是一次性的,CyclicBarrier 是可以重用的
  • CountDownLatch中有两个关键,一个是countDown(),一个是awit(),调用awit()的线程需要等待,调用countDown()的线程会将倒计时-1,不同的线程职责可能是不同的,被管理的是调用了awit()的线程,计数器可以是单独的逻辑。

Semaphore

信号量,用于控制同时访问某个资源的线程数量。它可以用来限制并发访问的线程数,或者用于线程间的信号通知。

Semaphore 维护了一组许可(permits),线程在访问资源之前必须先获得许可,如果许可数不足,则线程必须等待,直到有可用的许可为止。每个 Semaphore 对象都有一个初始许可数,表示可同时访问该资源的线程数。

假设信号量初始数量为sem:

P 操作:申请一个许可,将 sem 减 1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;

V 操作:释放一个许可,将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;

常用方法:

  • acquire(): 获取一个许可,如果没有可用许可,则线程会被阻塞,直到有可用许可。
  • release(): 释放一个许可,将其返回给 Semaphore
  • tryAcquire(): 尝试获取一个许可,如果获取成功则返回 true,否则返回 false。
  • availablePermits(): 返回当前可用的许可数。

ReadWriteLock

读写锁,控制对共享资源访问的同步机制。它允许多个线程同时读取资源,但只允许一个线程独占写入资源。

ReadWriteLock的目的是在资源被读取的频率高于写入的情况下优化性能。通过允许并发读取,多个线程可以同时访问资源,这可以提高吞吐量并减少线程之间的竞争。然而,当一个线程希望修改资源时,它需要独占访问以确保一致性。

Fork/Join

核心思想:分而治之

Fork-Join模型的核心思想是关键概念是”fork”(分叉)和”join”(合并),即将一个大任务划分为若干个小任务,然后并行地执行这些小任务,最后将它们的结果组合起来得到最终的结果。这个过程可以递归地进行,即每个小任务也可以再次划分成更小的子任务,直到任务的规模足够小以至于可以被直接执行。

两个实现类:

  • RecursiveTask有返回值
  • RecursiveAction没有返回值

Fork/Join框架的优势在于它能够自动地将任务划分成合适的大小,并利用多核处理器上的并行性提高程序的性能。它也提供了一些优化技术,如工作窃取(work stealing),可以确保各个线程在执行任务时能够充分利用系统资源。

工作窃取原理

  1. 每个工作线程都有一个本地的工作队列(双端队列),用于存储待执行的任务。使用双端队列作为本地工作队列的好处在于,工作线程可以高效地从队列的头部或尾部执行插入和删除操作。当工作线程执行任务时,它会从队列的头部获取任务并执行;而当工作线程尝试窃取任务时,它会从队列的尾部插入窃取到的任务。
  2. 当一个工作线程的本地工作队列为空时,它会尝试从其他工作线程的工作队列中窃取任务。这个窃取的目标通常是选择一个相对较繁忙的工作线程,即其工作队列中有更多任务等待执行。
  3. 工作线程可以从目标工作线程的工作队列的顶部(头部)或者底部(尾部)窃取任务。选择窃取的位置可以根据具体的实现策略来确定,不同的实现方式可能有所不同。
  4. 窃取任务的过程通常是通过线程间的原子操作来实现的,以确保并发的正确性。例如,可以使用CAS(Compare and Swap)操作来保证任务的窃取是原子的。
  5. 当一个工作线程成功地窃取到任务后,它会将任务添加到自己的本地工作队列中,并继续执行窃取到的任务。