JUC

1、进程与线程

进程与线程的概念

进程

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理IO的
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360安全卫士等)

线程

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行
  • Java中,线程作为最小调度单位,进程作为资源分配的最小单位。在windows中进程是不活动的只是作为线程的容器

二者区别

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集进程拥有共享的资源,如内存空间等,供其内部的线程共享
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为IPC (Inter-process communication)
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如HTTP
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

并发与并行的概念

单核cpu下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为15毫秒)分给不同的线程使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。总结为一句话就是:微观串行,宏观并行,

一般会将这种线程轮流使用cPu的做法称为并发,concurrent

应用

异步调用

从方法调用的角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步

  • 不需要等待结果返回,就能继续运行就是异步

注意:同步在多线程中还有另外一层意思,是让多个线程步调一致

设计

多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了5秒钟,如果没有线程调度机制,这5秒调用者什么都做不了,其代码都得暂停..

结论

  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程

  • tomcat 的异步servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞tomcat 的工作线程

  • ui程序中,开线程进行其他操作,避免阻塞ui线程

查看线程的方法

windows

  • 任务管理器可直接利用图形化窗口查看线程数和进程数,也可以用来杀死进程
  • tasklist:查看进程
  • taskkill:杀死进程

Linux

  • ps -ef 查看所有进程
  • ps -fT -p <pid> 查看某个进程的所有线程
  • kill 杀死进程
  • top -H 动态的查看 进程下的线程
  • top -H -p <pid> 动态的查看某个进程下线程

Java

  • jps : 查看所有java进程
  • jstack <Pid> : 查看某个进程下所有线程的状态
  • jconsole: 来查看某个 Java 进程中线程的运行情况(图形界面)

若要远程查看监控

  • 需要以如下方式运行你的 java 类
1
2
3
java -Djava.rmi.server.hostname=`ip地址` -Dcom.sun.management.jmxremote -
Dcom.sun.management.jmxremote.port=`连接端口` -Dcom.sun.management.jmxremote.ssl=是否安全连接 -
Dcom.sun.management.jmxremote.authenticate=是否认证 java类

如果要认证访问,还需要做如下步骤 复制 jmxremote.password 文件 修改 jmxremote.password 和 jmxremote.access 文件的权限为 600 即文件所有者可读写 连接时填入 controlRole(用户名),R&D(密码)

原理之线程运行

栈与栈帧

Java Virtual Machine Stacks (Java 虚拟机栈) 我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟 机就会为其分配一块栈内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存

  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

每个栈帧都配备一个PC程序计数器,程序计数器用于让CPU在进行上下文切换时记录下JVM在该线程所要执行的下一条指令的地址

image-20230702212725698

线程上下文切换

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

  • 线程的 cpu 时间片用完

  • 垃圾回收

  • 有更高优先级的线程需要运行

  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念 就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等

  • Context Switch 频繁发生会影响性能

线程方法

image-20230703134520023

sleep

  1. 调用sleep会让当前线程从Running进入到Timed Waiting状态
  2. 其他线程可以使用interrupt方法打断正在睡眠的线程,这时sleep方法会抛出InterrutedException
  3. 睡眠结束后的线程未必会立即得到执行
  4. 建议用TimeUnit的sleep代替Thread的sleep来获得更好的可读性

yield

  1. 调用yield会让当前线程从Running进入Runnable状态,然后调度执行其他同优先级的线程,如果这是没有同优先级的线程,那么不能保证让当前线程暂停的效果
  2. 具体的实现依赖于操作系统的任务调度器

线程优先级

  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果CPU比较忙,那么优先级高的线程会获得更多的时间片,但CPU闲时,优先级几乎没有作用

interrupt

打断正在执行的线程,提示线程是否被打断,该方法多用于无线循环的线程(while(true))的终止, 可调用方法interrupted()方法判断是否线程被终止,然后跳出循环(比较体面的打断线程,而不是想stop一样直接就打断了线程)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static void test2() throws InterruptedException {
Thread t2 = new Thread(()->{
while(true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
//判断是否被打断
if(interrupted) {
log.debug(" 打断状态: {}", interrupted);
break;
}
}
}, "t2");
t2.start();
sleep(0.5);
t2.interrupt();
}

在while(true)中我们可判断是否该程序已被打断,然后执行 接下来的语句(做一点善后工作),然后结束循环break

两阶段终止模式

Two Phase Termination

在一个线程T1中如何“优雅”终止线程T2? 这里的【优雅】指的是给T2一个料理后事的机会。

错误思路

  • 使用线程对象的stop()方法停止线程
    • stop方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁”
  • 使用System.exit(int)方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都停止

正确方式

image-20230703154613831

以下是两阶段终止模式的代码示例

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
public class main {
public static final Logger logger = LoggerFactory.getLogger(main.class);
public static void main(String[] args) throws InterruptedException {

TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();

Thread.sleep(3500);

tpt.stop();
}
static class TwoPhaseTermination{
private Thread monitor;
//启动监控线程
public void start(){
monitor=new Thread(()->{
while (true){
Thread current = Thread.currentThread();
if (current.isInterrupted()) {
System.out.println("料理后事");
break;
}
try {
Thread.sleep(1000);
System.out.println("执行监控记录");
} catch (InterruptedException e) {
e.printStackTrace();
//当报出InterruptedException时,线程会被清除interrupt标记,所以要重新打标记
current.interrupt();
}

}
});
monitor.start();
}
//停止监控线程
public void stop(){
monitor.interrupt();
}

}
}

park

在多线程编程中,park()是Java中java.util.concurrent.locks.LockSupport类的一个方法,用于暂停当前线程。当调用park()方法时,当前线程将会被阻塞,进入等待状态,直到被唤醒。

park()方法有两种形式:

  1. park(): 该方法会使当前线程进入等待状态,直到被唤醒。
  2. park(Object blocker): 该方法会使当前线程进入等待状态,并且会将一个相关的阻塞对象传递给park()方法,用于标识阻塞对象的状态,便于调试和监控。

park()方法的调用可以由其他线程通过unpark(Thread thread)方法进行唤醒,唤醒后的线程将会继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.concurrent.locks.LockSupport;

public class ParkExample {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("Thread started.");
LockSupport.park(); // 当前线程进入等待状态
System.out.println("Thread resumed.");
});

thread.start();

try {
Thread.sleep(2000); // 睡眠2秒钟,给线程运行一定的时间
} catch (InterruptedException e) {
e.printStackTrace();
}

LockSupport.unpark(thread); // 唤醒被阻塞的线程
}
}

在上面的代码中,我们创建了一个新线程,并在新线程中调用了LockSupport.park()方法使其进入等待状态。在主线程中,我们睡眠了2秒后,通过LockSupport.unpark()方法唤醒了被阻塞的线程。

需要注意的是,park()unpark()方法是基于线程的,而不是基于对象的,所以即使你在不同的线程中调用了unpark()方法,也能够唤醒指定的线程。

使用park()unpark()方法可以实现一些高级的线程同步和控制的功能。但请注意,使用它们要谨慎,确保正确的使用和避免出现死锁或无法唤醒的情况。

守护线程

在Java中,线程可以分为两种类型:用户线程(User Thread)和守护线程(Daemon Thread)。

守护线程(Daemon Thread)是一种在后台运行的线程,它的主要任务是为其他线程提供服务和支持。当所有的用户线程都结束时,守护线程会自动退出。与之相反,只要还有一个用户线程在运行,JVM就不会退出。

守护线程通常用于执行一些辅助性的、支持性的任务,如垃圾回收(Garbage Collection)线程就是守护线程的一种。守护线程的一个典型应用场景是在服务器程序中,用来处理与客户端的网络连接。当所有客户端连接都关闭时,守护线程会自动退出。

使用Java中的Thread类创建守护线程可以通过以下步骤实现

1
2
3
Thread daemonThread = new Thread(runnable);
daemonThread.setDaemon(true); // 设置线程为守护线程
daemonThread.start();

需要注意的是,在调用start()方法之前,必须先将线程设置为守护线程,否则会抛出IllegalThreadStateException异常。

守护线程的特点和注意事项如下:

  • 守护线程的生命周期受到其他用户线程的影响。当所有的用户线程结束时,守护线程会被强制终止,而不会等待其运行结束。
  • 守护线程不能够执行一些需要保证完整性的操作,如写文件等。因为守护线程在任何时刻都可能被终止,可能会导致数据不完整。
  • 守护线程一般不能访问用户线程的相关资源和状态,因为它们可能在任何时刻被终止,相关资源可能不存在或无效。

总之,守护线程是一种后台执行的线程,用于支持和服务于用户线程。它们的存在可以提供后台任务的支持,但需要注意它们的特性和使用限制。

线程的状态

由操作系统而来

image-20230703172905979

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联

  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行

  • 【运行状态】指获取了 CPU 时间片运行中的状态 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换

  • 【阻塞状态】 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入 【阻塞状态】 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑 调度它们

  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

2、并发模型

并发共享模型——管程

共享带来的问题

共享时常会带来一些其他麻烦:共享资源的线程安全问题

我们可以这样做一个实验

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
public class ThreadArithmetic {
static int count = 0;
static Object lock = new Object();

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {
int i = 0;

while (i < 5000) {
count++;
i++;
}

});
Thread t2 = new Thread(() -> {
int i = 0;

while (i < 5000) {
count--;
i++;

}
});

t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}

输出结果会是0吗

答案是:几乎不是,可是为什么会这样呢,明明是t1线程让i自加了5000次,t2线程让i自减了5000次。按这样说明明是应该结果为0的。

问题分析:

因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i

而对应 i– 也是类似:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i

在java1.7之后,JVM就将静态变量的存储到堆中了,静态变量在内存中只有一个所以自增自减都需要在内存中进行操作,

image-20230705103533051

如果在执行过程中不发生上下文切换,那么执行的时序图是这样的

image-20230705103630766

但在CPU对线程中的指令进行运行时,时常会出现线程的时间片用完而导致的上下文切换,这时的时序图就可能是这样

image-20230705103821055

首先是线程2先获得了CPU的执行权,在执行自增操作的四条指令中只执行了三条,在写入的时候,时间片用完了,PC程序计数器记下了getstatic i指令所编译的机器码指令,发生了上下文切换,然后线程1获取到了CPU的执行权继续执行执行过后写入i=1,发生了上下文切换,这时线程2获取到了CPU的执行权,CPU从PC中获取到了指令将结果写为了-1

临界区

一个程序运行多个线程本身是没有问题的 ,问题出在多个线程访问共享资源 ,多个线程读共享资源其实也没有问题 ,在多个线程对共享资源读写操作时发生指令交错,就会出现问题 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

1
2
3
4
5
6
7
8
9
10
11
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}

竞态条件

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

synchronized解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized

  • Lock 非阻塞式的解决方案:原子变量

使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文切换

虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的: 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

语法

1
2
3
4
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}

解决方式

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
package day2;

/**
* @author : 15754
* @version 1.0.0
* @since : 2023/7/4 21:09
**/


public class ThreadArithmetic {
static int count = 0;
static Object lock = new Object();

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {
int i = 0;
while (i < 5000) {
synchronized (lock) {
count++;
i++;
}
}
});
Thread t2 = new Thread(() -> {
int i = 0;

while (i < 5000) {
synchronized (lock) {
count--;
i++;
}
}
});

t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}

输出结果为:0

image-20230705105412172

你可以做这样的类比:

  1. synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人 进行计算,线程 t1,t2 想象成两个人
  2. 当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行 count++ 代码
  3. 这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切 换,阻塞住了
  4. 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦), 这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才 能开门进入
  5. 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count– 代码

下图表示为运行过程

image-20230705105759398

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切 换所打断。

  • 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?– 原子性 : 一次性执行5000次
  • 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?– 锁对象 : 相当于屋子有两把钥匙,还是会出现线程安全问题
  • 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?– 锁对象 : 相当于屋子上锁了,只对t1上锁,t2直接穿墙进屋(他自己还看不见门)

面向对象改进

把需要保护的共享变量放入一个类

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
class Room {
int value = 0;

public void increment() {
synchronized (this) {
value++;
}
}

public void decrement() {
synchronized (this) {
value--;
}
}

public int get() {
synchronized (this) {
return value;
}
}
}

public class Test1 {

public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(room.get());
}
}

方法上的 synchronized

对成员方法进行加 synchronized 实际上是对这个类的对象加锁

1
2
3
4
5
6
7
8
9
10
11
12
class Test{
public synchronized void test() {

}
}
等价于
class Test{
public void test() {
synchronized(this) {

}
}

对静态方法加锁实际上是对该类的字节码对象加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {

}
}
}

局部变量线程安全分析

以下代码会出现线程安全问题吗

1
2
3
4
public static void test1() {
int i = 10;
i++;
}

答案是:不会,因为他们的竞争资源压根不存在,静态代码方法中的资源并非 竞争资源,在虚拟机栈中每个栈帧会创建自己的局部变量,变量之间互不干扰如下图

image-20230705112246328

但我们要看引用的情况,如果是下面这种情况(引用的,无论是哪个线程引用的都是同一个对象)

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
package day2;

import java.util.ArrayList;


public class main {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;

public static void main(String[] args) {
ThreadUnsafe test = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}

class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();

public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2();
method3();// } 临界区
}
}

private void method2() {
list.add("1");
}

private void method3() {
list.remove(0);
}
}


在执行后直接报错,在内存中分析

image-20230705113813762

直接将list转化为ThreadUnsafe的局部变量即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ThreadUnsafe {

public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件
method2(list);
method3(list);// } 临界区
}
}

private void method2(ArrayList<String> list) {
list.add("1");
}

private void method3(ArrayList<String> list) {
list.remove(0);
}
}

image-20230705114137743

实际上是main线程中创建了一个ThreadUnsafe对象,存在堆中,同时ThreadUnsafe又在栈中创建了两个栈帧(线程)Thread-0 Thread-1 ,每个栈帧中的method1都创建了一个list对象,所以这两个线程在堆中就有两个list对象,所以就不会有竞态条件,因为list不再为竞争资源了

如果子类继承了ThreadUnsafe并重写了method3(2)方法在method3(2)方法内创建了一个新线程 Thread-2 那么,这个线程又变成了不安全的线程,如下图

image-20230705115914466

​ 在线程安全的类中使用方法也有可能出现线程不安全的情况

例如 :两个线程在运行同一个对象hashtable的 get put方法时可能会出现以下情况

1
2
3
4
5
6
7
8
sequenceDiagram
participant t1 as 线程1
participant t2 as 线程2
participant table
t1 ->> table : get("key")==null
t2 ->> table : get("key")==null
t2 ->> table : put("key",v2)
t1 ->> table : put("key",v1)

3、Monitor

Java对象头(这个对象头JVM中有描述,具体去看我JVM笔记)

以32位的虚拟机为例,普通对象,image-20230812132031541

数组对象

image-20230812132941585

Monitor(锁)

Monitor被翻译为监视器或管程

每个Java对象都可以关联一个Monitor对象,若果使用Synchronized给对象上锁后切锁升级为重量级后,该对象头的MarkWord中就被设置指向Monitor的对象指针

Monitor的结构如下

image-20230812133429928

  • 刚开始.Monitor 中 owner为null
  • 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
  • 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obij),就会进入EntryL.ist BLOCKED
  • Thread-2执行完同步代码块的内容,然后唤醒EntryList 中等待的线程来竞争锁,竞争的时是非公平的图中 WaitSet 中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITNG状态的线程,后面讲wait-notify时会分析
    • synchronized 必须是进入同一个对象的monitor才有上述的效果
    • 不加synchronized的对象不会关联监视器,不遵从以上规则

Synchroniazed原理

image-20230813205807144

对应的解释字节码

image-20230813210030201

看限免的Exception Table异常表,如果6-16行字节码指令出现问题或19 -22出现问题就会直接跳转到19指令上执行,其实对应的就是synchronized包住的代码块内的字节码命令

轻量级锁

轻量级锁的使用场景∶如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是synchronized工假设有两个方法同步块,利用同一个对象加锁

  • 创建锁记录((Lock Record)对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word

  • 让锁记录中Object reference指向锁对象,并尝试用cas替换Object的 Mark Word,将Mark Word的值存入锁记录

image-20230813212606944

  • 如果CAS替换成功,对象头存储了锁记录地址和状态00,表示由该线程给对象加锁,这时图示如下

image-20230813212617814

  • 如果CAS失败,有两种情况
    • 如果其他线程已经持有了该Obj的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是自己执行了synchronized锁冲入,那么再添一条Lock Record作为重入的计数

image-20230813213108467

  • 当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头

    • 成功,则解锁成功

    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时的一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁转变为重量级锁

当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁

image-20230813215518119

  • 这时Thread-1加轻量级锁失败,进入锁膨胀流程
    • 即为Object对象申请Monitor锁,让Object指向重量级锁地址
    • 然后自己进入Monitor的EntryList BLOCKED

image-20230813220139166

  • 当Thread-0退出同步块解锁时,使用cas将Mark Word的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒 EntryList 中 BLOCKED线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

自旋重试成功的情况

image-20230813230510392

自旋失败

image-20230813232359541

  • 在Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。

  • Java 7之后不能控制是否开启自旋功能

偏向锁

  • 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。
  • Java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程D设置到对象的Mark Word头,之后发现这个线程D是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有

这种方式只需要修改锁对象头的数据,看看是不是本线程ID,如果是本线程,那么则直接执行同步代码块,若不是则进入锁膨胀

偏向状态

image-20230814105925953

  • 一个对象创建时:

    如果开启了偏向锁(默认开启),那么对象创建后,markword值为0x05即最后3位为101,这时它的thread、epoch、age都为О

  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数-XX:BiasedLockingStartupDelay=o来禁用延迟

  • 如果没有开启偏向锁,那么对象创建后,markword值为0x01即最后3位为001,这时它的hashcode、age都为0,第一次用到hashcode时才会赋值

在锁对象创建后不要轻易对锁对象进行执行其他操作,例如调用了锁对象的hashcode()方法后,锁就只会升级成轻量级锁

wait/notify原理

image-20230820201230519

  • Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态
  • BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
  • BLOCKED线程会在Owner线程释放锁时唤醒
  • WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList重新竞争

API介绍

  • obj.wait()让进入object 监视器的线程到waitSet等待
  • obj.notify()在object 上正在waitSet等待的线程中挑一个唤醒
  • obj.notifyAll()让object 上正在waitSet 等待的线程全部唤醒

sleep与wait的区别

  • sleep是Thread方法,而wait是Object的方法
  • sleep不需要强制和synchronized配合使用,但wait 需要和synchronized一起用
  • sleep在睡眠的同时,不会释放对象锁的,但wait在等待的时候会释放对象锁。

同步模式之保护性暂停

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个GuardedObject
  • 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
  • JDK中,join的实现、Future的实现,采用的就是此模式
  • 因为要等待另一方的结果,因此归类到同步模式

image-20230820222607853

GuardedObject

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
public   class GuardedObject {
private Object respone;

public Object getRespone() {
synchronized (this) {
while (respone == null) {
System.out.println("有资源吗?");
try {
System.out.println("睡会儿");
wait();
System.out.println("睡醒了");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
System.out.println("获取到资源了");
return respone;
}

public void setRespone(Object respone) {
synchronized (this) {
System.out.println("把资源放进去");
this.respone = respone;
this.notifyAll();
}

}

public GuardedObject() {}
}

测试

1
2
3
4
5
6
7
8
public static void main(String[] args) {
String s="123";
GuardedObject guardedObject = new GuardedObject();
new Thread(guardedObject::getRespone).start();
new Thread(()->{
guardedObject.setRespone(s);
}).start();
}

输出结果

image-20230820225123816

join原理

与超时等待类似,join的原理很容易理解

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
public final synchronized void join(long var1) throws InterruptedException {
long var3 = System.currentTimeMillis();
long var5 = 0L;
if (var1 < 0L) {
throw new IllegalArgumentException("timeout value is negative");
} else {
if (var1 == 0L) {
while(this.isAlive()) {
this.wait(0L);
}
} else {
while(this.isAlive()) {
long var7 = var1 - var5;
if (var7 <= 0L) {
break;
}

this.wait(var7);
var5 = System.currentTimeMillis() - var3;
}
}

}
}

异步模式之生产者/消费者

  • 与前面的保护性暂停中的GuardObject 不同,不需要产生结果和消费结果的线程—一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  • JDK中各种阻塞队列,采用的就是这种模式

image-20230821103601391

生产者消费者模式

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
public class Test21 {
public static void main(String[] args) {
MessageQueue messageQueue = new MessageQueue(2);

for (int i = 0; i < 3; i++) {
int id = i;
new Thread(() -> {
messageQueue.put(new Message(id, "值" + id));
}, "生产者" + i).start();
}

new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
Message take = messageQueue.take();
System.out.println("消费到消费者:" + take.getId() + ",内容为:" + take.getValue());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}, "消费者").start();


}

}

class MessageQueue {
//消息队列存储容器
private final LinkedList<Message> list = new LinkedList<>(); //使用 LinkedList更适合这种头部和尾部的增删操作
//容量大小
private int capcity;


//获取消息
public Message take() {
synchronized (list) {
while (list.isEmpty()) {

try {
System.out.println("队列为空,消费者线程暂停");
list.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

Message message = list.removeFirst();
System.out.println("消息为:" + message);
list.notifyAll();
return message;
}
}

//放入消息
public void put(Message message) {
synchronized (list) {

while (list.size() == capcity) {
try {
System.out.println("队列满了,生产者线程暂停");
list.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
list.addLast(message);
list.notifyAll();
}

}

public MessageQueue(int capcity) {
this.capcity = capcity;
}
}

final class Message {
private int id;
private Object value;

public Message(int id, Object value) {
this.id = id;
this.value = value;
}

public int getId() {
return id;
}

public Object getValue() {
return value;
}

@Override
public String toString() {
return "Message{" +
"id=" + id +
", value=" + value +
'}';
}
}

Park与Unpark

基本使用

他们是LockSupport类中的方法

1
2
3
4
//暂停当前线程
LockSupport.park();
//恢复某个线程运行
LockSupport.unpark(暂停线程对象);

我们可以发现如果当主线程提前调用unpark时,在进行park,那么睡眠将无法睡眠

特点:

  • 与Object的 wait & notify相比
  • wait,notify和notifyAll必须配合Object Monitr一起使用,而unpark不必
  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而notify只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】
  • park & unpark可以先 unpark,而wait & notify不能先notify

park与unpark原理

image-20230821115117048

我们将线程比作一台汽车,_cond比作一个加油站,_counter比作汽车中的油(只有0油空,1油满两种状态)

当调用park时,首先会检查counter是否为0,如果为0,counter为0,汽车没油了,要去_cond加油,线程进入暂定运行,当调用unpark时,车加上油了,_counter置为1,并将counter置为0,线程继续运行

当先调用unpark时(此时counter为0),counter就会变成1,当线程再次调用park时,再检查counter是否为0,发现不为0,则先将counter置为0,然后线程继续执行

重新理解线程状态

image-20230821120111793

1:NEW—>RUNNABLE

当调用t.start()方法时,由NEW—>RUNABLE

2:RUNNABLE<----------->WAITING

t线程用synchronized(obj)获取了对象锁后

  • 调用obj.wait()方法时,t线程从RUNNABLE<---------->WAITING
  • 调用obj.notify(),obj.notifyAll(),obj.interrupt()时
    • 竞争锁成功,t线程从WAITING—–>RUNABLE
    • 竞争锁失败,t线程从RUNABLE —–>WAITING

3: RUNNABLE<----------->WAITING

  • 当前线程调用t.join()方法时,当前线程从RUNNABLE------->WAITING
    • 注意时当前线程在t线程的监视器上等待
  • t线程运行结束,或调用了当前线程的interrupt时,当前线程从 WAITING ------->RUNNABLE

4: RUNNABLE<----------->WAITING

  • 当前线程调用LockSupport.park()方法会让当前线程从RUNNABLE——>WAITING
  • 调用LockSupport.unpark(目标线程)或调用了线程的interrupt(),会让目标线程从WAITING——–>RUNNABLE

5:RUNNABLE<----------->TIMED_WAITING

  • t线程调用synchronized(obj)获取对象锁后
    • 调用obj.wait(long n) 方法时,t线程从 RUNNABLE —>TIMED_WAITING
    • t线程等待时间超过n毫秒后,或调用obj.notify(),obj.notifyAll(),t.interrupt()时
      • 竞争锁成功,t线程从TIMED_WAITING——–>RUNNABLE
      • 竞争锁失败,t线程从TIMED—->BLOCKED

6:RUNNABLE<----------->TIMED_WAITING

  • 当前线程调用t.join(long n )方法 ,当前线程从RUNNABLE——>TIMED_WAITING
    • 注意是当前线程在t线程对象的监视器上等待
  • 当前线程等待时间超过n毫秒或t线程运行结束,或调用当前线程的interrupt()时,当前线程从TIMED_WAITING———->RUNNABLE

7:RUNNABLE<----------->TIMED_WAITING

  • 当前线程调用Thread.sleep(long n),当前线程从RUNABLE——->TIMED_WAITING
  • 当前线程等待时间超过n毫秒,当前线程从TIMED——–>RUNNABLE

8:RUNNABLE<———–>TIMED_WAITING

  • 当前线程调用LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis)时,当前线程从RUNNABLE—–>TIMED_WAITING
  • 调用LockSupport.unpark(目标线程)或调用了线程的interrupt()或是等待超时,会让目标线程从TIMED_WAITING——>RUNNABLE

9:RUNNABLE<———–>BLOCKED

  • t线程用synchronized(obj) 获取了对象锁时,如果竞争失败,从RUNABLE——–>BLOCKED
  • 池OBJ锁的线程的同步代码块执行完毕,会唤醒该对象上所有BLOCKED的线程重新竞争, 如果t线程竞争成功,从BLOCKED——–>RUNNABLE,其他失败的线程依然BLOCKED

10 RUNNABLE———>TERMINATED

当前线程所有代码运行完毕进入TERMINATED

多把锁

如果要想让两个互不相干的线程同时执行不同的操作,但是所支配的资源是线程不安全的(两个线程所需资源不同),那么我们设计一把锁,同时限制两个资源,会大大影响程序的效率,所以我们可以设计多把锁

多把锁,将锁的粒度细分

  • 好处,是可以增强并发度
  • 坏处,如果一个线程需要同时获得多把锁,就容易发生死锁

活跃性

死锁

有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁

  • t1 线程获得A对象锁,接下来想获取B对象的锁
  • t2线程获得B对象锁,接下来想获取A对象的锁
活锁

例如

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
static volatile int i = 10;

public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while(i<20){
try {
Thread.sleep(200);
System.out.println("加程序:"+i);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
i++;
}
}).start();

new Thread(()->{
while(i>0){
try {
Thread.sleep(200);
System.out.println("减程序:"+i);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
i--;
}
}).start();
}

两个线程互相改变对方退出循环的条件,这样导致线程无法结束完成,这样的情况称为活锁

饥饿

一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束。

ReenrantLock

相对于synchronized它具备如下特点·

  • 可中断
  • ·可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量(意味着支持多个WaitSet)

与Sychronized一样,都支持可重入

1
2
3
4
5
6
7
8
9
ReentrantLock reentrantLock=new ReentrantLock();
reentrantLock.lock(); //获取锁
try {
//临界区
}
finally {
reentrantLock.unlock(); //释放锁
}

可重入

可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁,如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住

可打断

​ 调用reentrantLock的 localInterruptibly(),可使得正在获取锁的线程不用一直处于阻塞状态,进而被打断

条件变量

synchronized 中也有条件变量,就是我们讲原理时那个waitSet休息室,当条件不满足时进入waitSet等待

ReentrantLock的条件变量比synchronized强大之处在于,它是支持多个条件变量的,这就好比:

  • synchronized是那些不满足条件的线程都在一间休息室等消息
  • 而ReentrantLock支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒

使用流程

  • await 前需要获得锁
  • await执行后,会释放锁,进入conditionObject等待
  • wait的线程会被唤醒(或打断或超时)去重新竞争lock锁
  • 竞争lock锁成功后,从await后继续执行
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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public class reentrantLockDemo {
static ReentrantLock lock = new ReentrantLock();
static Condition waitCigaretteSet = lock.newCondition();
static Condition waitTakeOutSet = lock.newCondition();
static boolean hasCigarette = false;
static boolean hasTakeOut = false;

public static void main(String[] args) throws InterruptedException {

new Thread(() -> {
lock.lock();
try {
System.out.println("有烟吗?" + hasCigarette);
while (!hasCigarette) {
waitCigaretteSet.await();
}
System.out.println("有烟咯,干活咯");

} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();
new Thread(()->{
lock.lock();
try {
System.out.println("外卖到了吗?" + hasTakeOut);
while (!hasTakeOut) {
waitTakeOutSet.await();
}
System.out.println("外卖来咯,吃饭咯");

} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();


new Thread(()->{
lock.lock();
try {
hasTakeOut=true;
waitTakeOutSet.signal();
System.out.println("外卖到了");

}
finally {
lock.unlock();

}

}).start();


new Thread(()-> {
lock.lock();
try {
Thread.sleep(3000);
hasCigarette=true;
waitCigaretteSet.signal();
System.out.println("烟送到了");
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
lock.unlock();
}

}).start();



}
}

Java内存模型

JMM即Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。
JMM体现在以下几个方面

  • ·原子性-保证指令不会受到线程上下文切换的影响
  • ·可见性-保证指令不会受cpu缓存的影响
  • ·有序性-保证指令不会受cpu指令并行优化的影响

可见性

看下面的代码

1
2
3
4
5
6
7
8
9
static boolean flag=true;

public static void main(String[] args) throws InterruptedException {
while (flag){
}
System.out.println("开始无限循环");
Thread.sleep(1000);
flag=false;
}

当我们看到时,我们一般会的出结果,先循环一秒然后执行结束,但结果并非如此image-20230830083931083

在这里执行过后,并未结束,但是什么也没有得到,为什么会是这样呢?

  1. 在初始状态时,t线程刚开始从主内存读取了run值到工作内存image-20230830084340567
  2. 因为t线程要品换从主内存读取run值,JIT编译器会将run值缓存到自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率image-20230830084856473
  3. 一秒过后,main线程修改了run值,并同步至主存,而t是从自己工作内存中多呢高速缓存中读取这个变量的值,结果永远是旧值image-20230830085202981

volatile

关键字,意为易变的

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存

Sychonized也能解决这个问题,对于该情况,也能实现可见性,但Sychonized要比volatile更重量级一些,sychonized涉及到monitor而volatile则不涉及

可见性 VS 原子性

原子性(Atomicity)和可见性(Visibility)是并发编程中两个重要的概念,它们分别描述了多线程或多进程环境中操作的不同特性。

  1. 原子性(Atomicity): 原子性指的是一个操作是不可分割的单个单位,要么完全执行,要么完全不执行,中间不会被其他操作打断。在并发环境中,如果一个操作被标记为原子操作,意味着它在执行时不会被其他线程或进程的操作干扰,从而保证了数据的一致性。例如,一个原子操作可以是对共享变量的读取、修改和写入,这个操作要么全部执行,要么完全不执行。
  2. 可见性(Visibility): 可见性指的是一个线程对共享变量的修改在之后的操作中能被其他线程正确地感知到。在多线程环境中,线程之间可能存在缓存不一致的情况,即一个线程修改了共享变量的值,但是其他线程并不立即能够看到这个修改。为了确保可见性,需要使用同步机制来保证共享变量的修改能够被及时地同步到其他线程的视图中,从而避免出现不一致的情况。

区别总结:

  • 原子性关注操作的不可分割性,要么全部执行,要么完全不执行。
  • 可见性关注操作的结果对其他线程的可见性,即修改的结果能否被其他线程正确感知。
  • 原子性保证操作的完整性,可见性保证操作的结果在多线程环境中正确传播。
  • 原子性通常通过使用锁或原子操作来实现,而可见性通常通过使用同步机制(如volatile关键字、锁、内存屏障等)来实现。

在并发编程中,理解并正确处理原子性和可见性问题是确保程序正确性和性能的关键。

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况:
上例从字节码理解是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
getstatic
run //线程t获取run true
getstatic
runl //线程t获取run true
getstatic
run //线程t获取run true
getstatic
runl //线程t获取 run true
putstatic
run //线程main修改 run为 false,仅此一次
getstatic
run //线程t 获取 run false

对于一个线程写其他线程读这种情况需要保证可见性,要对更改的变量添加volatile,但是如果保证在同一时刻下只有一个线程能够运行代码块中的代码,需要保证其原子性,是需要在利用sychonized包裹代码块

比如我如果要去用两个线程打印1-100,外面设置了一个静态变量i,那么这个变量i可以由任意一个线程访问,那么这样的话,我们不对i加锁就会出现,打印出多个任意数字或不打印其中的某个值 ,这时我们需要加锁保证只有一个线程访问到静态变量i

设计模式—–犹豫模式

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回

我们需要创建一个监控线程,用于监控整个程序,由于监控线程只有一个(单例模式),所以我们要保证监控线程只有一个,同时我们也需要有时打断该线程

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
54
public class demo {

public static void main(String[] args) throws InterruptedException {
TowPhaseTermination two = new TowPhaseTermination();

two.start();
two.start();
Thread.sleep(5000);
System.out.println("停止监控");
two.stop();
two.stop();

}


}

class TowPhaseTermination {
private Thread monitorThread;
private volatile boolean stop = false;
private boolean starting = false;


public void start() {
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
monitorThread = new Thread(() -> {
while (true) {
if (stop) {
System.out.println("料理后事");
break;
}
try {
Thread.sleep(1000);
System.out.println("执行监控机记录");
} catch (InterruptedException e) {
}

}
}, "monitor");
monitorThread.start();
}


public void stop() {
stop = true;
monitorThread.interrupt();
}

}

如果是这样的话,线程要第一时间对stop这个变量进行判断,是否要结束方法,所以要保证stop变量的可见性,不能读取工作区中的缓存,所以要对其加volatile关键字,同时如果保证一个线程只能执行一次代码块中的内容要加sychonized

volatitle原理

volatile的底层实现原理是内存屏障,Memory Barrier (Memory Fence)

  • ·对volatile变量的写指令后会加入写屏障
  • 对volatile变量的读指令前会加入读屏障I

如何保证可见性

在计算机系统中,读写屏障(Memory Barrier)是用于控制多处理器(或多核)系统中的内存操作顺序和可见性的概念。读写屏障被用来保证对共享数据的读取和写入操作能够满足一定的内存一致性和顺序性要求。

  1. **写屏障(Store Memory Barrier)**: 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对写屏障之后的读或者写是可见的。
  2. **读屏障(Load Memory Barrier)**:处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。

读写屏障是为了解决内存模型中的一致性问题,特别是在并发编程中,多个线程或多个处理器同时操作共享数据时,可能会出现的数据不一致性、可见性问题。通过使用读写屏障,可以在必要时强制执行特定的内存操作顺序,从而确保操作的正确性。

需要注意的是,现代处理器和编程语言(如Java)通常会在一些同步原语(如锁、volatile关键字等)的实现中隐含地使用读写屏障,从而提供一定的内存可见性和顺序性。不同的硬件和编程语言可能在这方面有不同的机制和实现。

Happens-before

happens-before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见 ,与上面的读屏障一致,就是读操作要监控着写操作,在读操作之前的写操作,要保证可见性,将数据同步到主存中。

  • 线程对volatile变量的写,对接下来其它线程对该变量的读可见。
  • 线程start前对变量的写,对该线程开始后对该变量的读可见
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用tl.isAlive()或t1L.join()等待它结束),
  • 线程tl打断t2 ( interrupt)前对变量的写,对于其他线程得知t﹖被打断后对变量的读可见(通过t2.interrupted或 t2.isInterrupted)

单例的线程安全习题

单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用getInstance)时的线程安全,并思考注释中的问题
饿汉式:类加载就会导致该单实例对象被创建
懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

对于饥汉式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//问题1:为什么加 final   加上final防止被继承,子类实例化破坏单例性
//问题2:如果实现了序列化接口,还要做什么来防止反序列化破坏单例
// 序列化的话,将一个对象从序列化的状态反序列化为对象,如果内存中有这个单例对象,那么反序列化无异于是又创建了一个单例对象,破坏了单例性,所以需要实现方法 readResovle() 当反序列化时,首先会调用readResovle方法,如果内存中存在INSTANCE那么直接调用返回单例对象
public final class singleton implements serializable {
//问题3:为什么设置为私有?是否能防止反射创建新的实例?
//不可以,反射可调用declareConstructor方法调用出对象声明过的构造器,忽略访问修饰符
private singleton() {}
//问题4:这样初始化是否能保证单例对象创建时的线程安全?
//线程安全,静态成员方法的初始化,是在类加载时完成的,类加载是线程安全的,所以这样单例对象创建时线程安全的
private static final singleton INSTANCE = new singleton( );
//问题5:为什么提供静态方法而不是直接将工NSTANCE设置为 public,说出你知道的理由
public static singleton getInstance() {
return INSTANCE;
}
public object readResovle( ) {
return INSTANCE;
}
}

对于枚举实现

1
2
3
4
5
6
7
8
9
10
//问题1:枚举单例是如何限制实例个数的
//问题2:枚举单例在创建时是否有并发问题
//问题3:枚举单例能否被反射破坏单例
//问题4:枚举单例能否被反序列化破坏单例
//问题5:枚举单例属于懒汉式还是饿汉式
//问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
enum singleton {
INSTANCE;
}

懒汉式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class singleton {
private singleton( ) { }
//千万别用INSTANCE做锁对象啊, sychonized 锁住的对象不能够是Instance,学过JVM的都知道,sychonized底层原理是根据锁对象头中的信息去判断是什么锁,如果一个锁对象,一直发生变化,对于sychonized性能将产生影响
private static singleton INSTANCE = null;
//分析这里的线程安全,并说明有什么缺点
//锁的范围太大了影响程序效率
public static synchronized singleton getInstance( ) i
if(INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new singleton( );
return INSTANCE;
}
}

懒汉式,双重检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public final class singleton { 
private singleton() { }
//问题1:解释为什么要加 volatile ? 保证可见性,因为当一个线程创建INSTANCE时,要保证其他线程能够在主存中看到这个对象
private static volatile singleton INSTANCE = null;
//问题2:对比实现3,说出这样做的意义 // 减小锁的粒度,提升程序的性能
public static singleton getInstance( ) {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (singleton.class) {
//问题3:为什么还要在这里加为空判断,之前不是判断过了吗
//保证线程安全,如果是两个线程同时进入非Synchonized修饰的代码块中,线程A、B判断时都没有INSTANCE对象被创建,那么则会两个对象各会返回一个INSTANCE,这样就无法保证单例性了
if ( INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new singleton( );
return INSTANCE;
}
}
}

静态内部类实现

1
2
3
4
5
6
7
8
9
10
public final class singleton i
private singleton() i }
//问题1:属于懒汉式还是饿汉式
private static class LazyHolder i
static final singleton INSTANCE = new Singleton();}I
//问题2:在创建时是否有并发问题
public static singleton getInstance( ) {
return LazyHolder . INSTANCE;
}
}

通过静态内部类来实现单例模式

  1. Singleton 类是一个被声明为 final 的类,这意味着它不能被继承。
  2. private Singleton() 构造函数是私有的,这样其他类就无法通过构造函数来实例化 Singleton 类。
  3. LazyHolder 是一个静态内部类,它在 Singleton 类中被定义。由于它是静态内部类,只有在被首次引用时才会加载。
  4. LazyHolder 内部,有一个名为 INSTANCE 的静态、不可变的(final)单例对象,它在类加载时被创建。由于 Java 的类加载机制,这会确保只有在第一次调用 LazyHolder 类时才会初始化 INSTANCE,从而实现了懒加载。
  5. getInstance() 方法是单例的访问点。当这个方法被调用时,它返回 LazyHolder.INSTANCE,这会触发 LazyHolder 的加载,从而初始化单例对象。

这种方式利用了类加载机制的特性,确保只有在需要时才会初始化单例对象,从而在一定程度上提高了性能。同时,由于静态初始化在类加载时是线程安全的,这个实现也保证了线程安全的单例对象创建。

保护共享资源

有一个账户Account,有属性余额balance,有1000个银行同时操作他的账户,请问如何保证资源的线程安全

加锁方式:

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
54
class AccountUnsafe implements Account{
//加上volatile关键字,保证balance的可见性
private volatile Integer balance;


public AccountUnsafe(Integer balance){
this.balance=balance;
}
public Integer getBalance(){
return this.balance;
}

public void withdraw(Integer count){
//加锁保证线程安全
synchronized (AccountUnsafe.class){
this.balance-=count;
}
}
public void demo(Account account){
List<Thread> ts=new ArrayList<>();
for(int i=0;i<1000;i++){
ts.add(new Thread(()-> account.withdraw(10)));
}
long startTime=System.nanoTime();

ts.forEach(Thread::start);

ts.forEach(t->{
try {
t.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});

long now = System.nanoTime();
System.out.println(now-startTime);


}
}
interface Account{
Integer getBalance();
void withdraw(Integer count);
void demo(Account account);
}
//测试结果


public static void main(String[] args) {
Account account = new AccountUnsafe(10000);
account.demo(account);
System.out.println(account.getBalance());
}

结果image-20230831160058983

不加锁

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
interface Account{
Integer getBalance();
void withdraw(Integer count);
void demo(Account account);
}
class AccountSafe implements Account{
private AtomicInteger balance;

@Override
public Integer getBalance() {
return balance.get();
}

@Override
public void withdraw(Integer count) {
while (true){
int prev=balance.get();
int next=prev-count;
if (balance.compareAndSet(prev,next)) {
break;
}
}
}

@Override
public void demo(Account account) {
List<Thread> ts=new ArrayList<>();
for(int i=0;i<1000;i++){
ts.add(new Thread(()-> account.withdraw(10)));
}
long startTime=System.nanoTime();

ts.forEach(Thread::start);

ts.forEach(t->{
try {
t.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});

long now = System.nanoTime();
System.out.println(now-startTime);
}
public AccountSafe(int balance){
this.balance=new AtomicInteger(balance);
}


}

image-20230831160846926

CAS与volatile

前面看到的AtomicInteger的解决方法,内部并没有用锁来保护共享变量的线程安全。那么它是如何实现的呢?

1
2
3
4
5
6
7
8
9
10
public void withdraw(Integer count) {
while (true){
int prev=balance.get();
int next=prev-count;
//比较并设置值
if (balance.compareAndSet(prev,next)) {
break;
}
}
}

其中的关键是compareAndSet,它的简称就是CAS(也有Compare And Swap 的说法),它必须是原子操作。

volatile

获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。即一个线程对volatile变量的修改,对另一个线程可见。

为什么无锁效率高

无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。打个比喻
线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速…恢复到高速运行,代价比较大
但无锁情况下,因为线程要保持运行,需要额外CPU的支持,CPU在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。

CAS的特点

结合CAS和volatile可以实现无锁并发,适用于线程数少、多核CPU的场景下。

  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系我吃亏点再重试呗。
  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
  • CAS体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思
  • 因为没有使用synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一·
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

原子整数

JUC并发包提供了:

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong

ABA问题

主线程仅能判断出共享变量的值与最初值A是否相同,不能感知到这种从A改为B又改回A的情况,如果主线程希望:
只要有其它线程【动过了】共享变量,那么自己的cas就算失败,这时,仅比较值是不够的,需要再加一个版本号

原子引用

  • AtomicReference //原子引用,用法 AtomicReference <Reference>

  • AtomicMarkableReference

    1
    2
    AtomicStampedReference可以给原子引用加上版本号,追踪原子引用整个的变化过程,如:A -> B->A -> c,通过AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。
    但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了AtomicMarkableReference
  • AtomicStampedReference //原子戳引用,用法

    1
    2
    3
    4
    AtomicStampedReference`<String>` *a* = new AtomicStampedReference`<String>`("A",1);
    int stamp = a.getStamp();
    a.compareAndSet(a.getReference(),"B",stamp,stamp+1);//将原子引用版本1,调整到版本号2,并将引用“A”变为引用“B”

    image-20230904220312151

原子数组

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

字段更新器

  • AtomicReferenceFieldUpdater !/域字段
  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater

利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合volatile修饰的字段使用,否则会出现异常

享元模式

体现

包装类

在JDK中Boolean,Byte,Short,Integer,Long,Character等包装类提供了valueOf方法,例如Long的valueOf会缓存-128127之间的Long对象,在这个范围之间会重用Byte, Short,Long 缓存的范围都是-128-127Character缓存的范围是0127

Integer的默认范围是-128-127,最小值不能变,但最大值可以通过调整虚拟机参数-Djava. lang.Integer.Integercache.high来改变
Boolean缓存了TRUE和FALSE对象,大于这个范围,才会新建Long对象

String串池

数据库连接池

final原理

1
2
3
public class TestFinal {
private final int a=20;
}

对应的字节码

1
2
3
4
5
6
7
 0 aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 aload_0
5 bipush 20
7 putfield #2 <JUC/TestFinal.a : I>
<====写屏障
10 return

发现final变量的赋值也会通过putfield指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为0的情况

无状态

在web阶段学习时,设计Servlet时为了保证其线程安全,都会有这样的建议,不要为Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的
因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】

线程池

image-20230907100218624

1
2
3
4
5
6
7
8
9
10
//线程池的构造方法
public ThreadPoolExecutor(
int corePoolSize , //核心线程数
int maximumPoolSize,// 最大线程数
long keepAliveTime, // 生存时间
TimeUnit unit, //时间单位:针对急救线程
BlokingQueue<runnable> workQueue,//阻塞队列
ThreadFactory threadFacotory,// 线程池工厂
RejectedExecutionHandler handler// 拒绝策 略
)

当线程池创建后,我们的核心线程有2个,最大线程数为3,阻塞队列的大小为2 ,当来第一个和第二个任务来临时,核心线程会优先执行这两个任务(1,2),此后又来了俩个任务,这两个任务 (3 4)被放到了阻塞队列中,很不巧又来了一个任务5,此时任务5会被线程池的救急线程执行,非常不巧的是又来了一个任务6,此时我们的线程池看这样不行呀,阻塞队列和线程池都满了,于是抛出了我们的拒绝策略image-20230911152003840

  • 线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。

  • 当线程数达到corePoolSize并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue队列排队,直到有空闲的线程。

  • 如果队列选择了有界队列,那么任务超过了队列大小时,会创建maximumPoolSize - corePoolSize数目的线程来救急。

  • 如果线程到达maximumPoolSize仍然有新任务这时会执行拒绝策略。拒绝策略jd提供了4种实现,其它著名框架也提供了实现

    • AbortPolicy让调用者抛出RejectedExecutionException异常,这是默认策略- CallerRunsPolicy让调用者运行任务

    • .DiscardPolicy放弃本次任务

    • -DiscardOldestPolicy放弃队列中最早的任务,本任务取而代之

    • . Dubbo的实现,在抛出RejectedExecutionException异常之前会记录日志,并dump线程栈信息,方便定位问题

    • Netty的实现,是创建一个新线程来执行任务

    • ActiveMQ的实现,带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略PinPoint的实现,它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略

  • 当高峰过去后,超过corePoolSize的救急线程如果一段时间没有任务做,需要结束节省资源,这个时间由keepAliveTime和unit来控制。

image-20230911211058767

根据这个构造方法,JDK Executors类提供了众多工厂方法来创建各种用途的线程池

Excutors

newFixedThreadPool

构造方法

1
2
3
4
public static ExecutorService newFixedThreadPool(int nThreads){
return new HteradPoolExecutor(nThreads,nTherads,0L,TimeUnit.MILLSECONDS,
new LinkedBlockingQueue<Runable>());
}

特点:

  • 核心线程数==最大线程数(没有急救线程被创建),因此也无需超时时间
  • 阻塞队列是无界的,可以放任意数量的任务
  • 特点:适用于任务量已知,相对耗时的任务

newCachedThreadPool

构造方法

1
2
3
public static ExecutorService newCachedThreadPool(){
return new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L,TimeUnit.SECONDS,new SychronousQueue<Runnable>());
}

特点:

  • 核心线程数是0,最大线程数是 Integer.MAX_VALUE,急救线程的空间生存时间是60s,意味着
    • 全部都是救急线程(60s后可回收)
    • 救急线程可以无线创建
  • 队列采用了SynchronousQueue实现特点是,它没有容量,没有现成来取是放不进去的(一手交钱、一手交货)
  • 评价:整个线程池表现为线程数会根据任务量不断增长没有上线,当任务执行完毕,空闲一分钟后释放线程,适合任务比较密集,但每个人物执行时间比较短的情况

newSingleThreadExecutor

构造方法

1
2
3
public static ExecurotService new SingleThreadExecutor(){
return new FinlizableDelegateExecutorService(new ThreadPoolExecutor(1,1,0L,TimeUnit.MILLSECONDS,new LinkedBolockingQueue<Runnable>())
}

使用场景:
希望多个任务排队执行。线程数固定为1,任务数多于1时,会放入无界队列排队。任务执行完毕,这唯一的线程也不会被释放。
区别:
自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作

  • Executors.newSingleThreadExecutor()线程个数始终为1,不能修改
    • FinalizableDelegatedExecutorService应用的是装饰器模式,只对外暴露了ExecutorService接口,因此不能调用ThreadPoolExecutor 中特有的方法
  • Executors.newFixedThreadPool(1)初始时为1,以后还可以修改
    • 对外暴露的是ThreadPoolExecutor对象,可以强转后调用setCorePoolSize等方法进行修改

提交任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//执行任务
void execute(Runnable command);
// 提交任务task,用返回值Future获得任务执行结果
<T> Future<T> submit(Callable<T> task)
//提交所欲任务task
<T> List<Future<T>> invokeAll(Collection< ? extends Callable<T>> tasks) throws InterrupteException;
//提交tasks中所有任务,带超时时间
<T> List<Future<T>> invokeAl1(Collection<? extends callable<T>> tasks,long timeout,TimeUnit unit)throws InterruptedException;

// 消
<T> T invokeAny(collection< ? extends callable<T>> tasks)
throws InterruptedException,ExecutionException;
//提交tasks中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间
<T> T invokeAny(collection< ? extends callable<T>> tasks,long timeout,TimeUnit unit)throws InterruptedException,ExecutionException,TimeoutException;

关闭线程池

shutdown

1
2
3
4
5
6
7
/*
线程池状态变为SHUTDOWN
- 不会接收新任务
- 但提交的任务会执行完
- 此方法不会阻塞调用线程的执行
*/
void shutdown();

shutdownNow

1
2
3
4
5
6
7
/*
线程池状态变为stop
-不会接收新任务
- 会将队列中的人物返回
- 并用interrupt的方式终端正在执行的任务
*/
List<Runnable> shutdownNow();

异步模式之工作线程

定义

让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现就是线程池,也体现了经典设计模式中的享元模式。

例如,海底捞的服务员(线程),轮流处理每位客人的点餐(任务),如果为每位客 人都配一名专属的服务员,那么成本就太高了(对比另一种多线程设计模式:Thread-Per-Message)

注意,不同任务类型应该使用不同的线程池,这样能够避免饥饿,并能提升效率

例如,如果一个餐馆的工人既要招呼客人(任务类型A),又要到后厨做菜(任务类型B)显然效率不咋地,分成服务员(线程池A)与厨师(线程池B)更为合理,当然你能想到更细致的分工

饥饿

固定大小线程池会有饥饿现象

  • 两个工人是同一个线程池中的两个线程
  • 他们要做的事情是:为客人点餐和到后厨做菜,这是两个阶段的工作
    • 客人点餐:必须先点完餐,等菜做好,上菜,在此期间处理点餐的工人必须等待
    • ·后厨做菜:没啥说的,做就是了
  • 比如工人A处理了点餐任务,接下来它要等着工人B把菜做好,然后上菜,他俩也配合的蛮好·但现在同时来了两个客人,这个时候工人A和工人B都去处理点餐了,这时没人做饭了,死锁

创建多少线程池合适

  • 过小会导致程序不能充分地利用系统资源、容易导致饥饿
  • 过大会导致更多的线程上下文切换,占用更多内存

CPU密集型运算

通常采用cpu核数+1能够实现最优的CPU利用率,+1是保证当线程由于页缺失故障(操作系统) 或其它原因导致暂停时,额外的这个线程就能顶上去,保证CPU时钟周期不被浪费

I/O密集型运算

CPU不总是处于繁忙状态,例如,当你执行业务计算时,这时候会使用CPU资源,但当你执行IO操作时、远程RPC调用时,包括进行数据库操作时,这时候CPU就闲下来了,你可以利用多线程提高它的利用率。

公式:`线程数=核数期望cPU 利用率总时间(CPU计算时间+等待时间)/CPU计算时间

例如4核CPU计算时间是50%,其它等待时间是50%,期望cpu被100%利用,套用公式:4* 100%*100% / 50% = 8

任务调度线程池

在『任务调度线程池』功能加入之前,可以使用java.util.Timer 来实现定时功能,Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务。

newScheduledThreadPool

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
 public static void main(String[] args) {
//创建定时线程池
ScheduledExecutorService pool = Executors.newScheduledThreadPool(1);
System.out.println("start……");
//按照固定时间执行线程 延时时间是1秒,每隔1秒就执行一次任务,倘若执行任务的时间超过所隔间隔时间 那么任务执行完,就会执行下一个任务,而不会在前一个任务未执行完毕时执行下一个任务
pool.scheduleAtFixedRate(()->{
System.out.println("running");
},1,1, TimeUnit.SECONDS);
}

public static void main(String[] args) {
//创建定时线程池
ScheduledExecutorService pool = Executors.newScheduledThreadPool(5);
System.out.println("start……");
//每个线程执行的时间间隔一致 延时时间是1秒,上次任务完成后再隔一秒执行下一个任务,倘若任务执行时间超过时间间隔,那么下一个任务也要等到,上一个任务完成一秒后再执行下一个任务
pool.scheduleWithFixedDelay(()->{
System.out.println("running");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
},1,1, TimeUnit.SECONDS);
}


正确处理线程池异常

  • 使用try catch 包裹代码块

  • 使用Future<T>获取 执行后的对象,倘若出现问题在Future对象执行get方法时即可获取异常

Tomcat线程池

Tomcat哪里用到线程池了

image-20230915220812304

  • LimitLatch用来限流,可以控制最大连接个数,类似J.U.C中的 Semaphore
  • Acceptor只负责【接收新的spcket连接】
  • Poller只负责监听socket channel是否有【可读的IO事件】
  • 一旦可读,封装一个任务对象(socketProcessor),提交给Executor线程池处理
  • Executor线程池中的工作线程最终负责【处理请求】

Tomcat线程池扩展了ThreadPoolExecutor,行为稍有不同

  • 如果总线程数达到maximumPoolSize
    • 这时不会立刻抛RejectedExecutionException异常
    • 而是再次尝试将任务放入队列,如果还失败,才抛出RejectedExecutionException异常

Connector配置

image-20230915222644940

Executor线程配置

image-20230915224650961

image-20230915224658324

Fork /Join

  • Fork/Join是JDK 1.7加入的新的线程池实现,它体现的是一种分治思想,适用于能够进行任务拆分的cpu密集型运算
  • 所谓的任务拆分,是将一个大任务拆分为算法上相同的小任务,直至不能拆分可以直接求解。跟递归相关的一些计算,如归并排序、斐波那契数列、都可以用分治思想进行求解
  • Fork/Join在分治的基础上加入了多线程,可以把每个任务的分解和合并交给不同的线程来完成,进一步提升了运算效率
  • Fork/Join默认会创建与cpu核心数大小相同的线程池
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

public class TestForkJoin {
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool(4);
Integer invoke = pool.invoke(new MyTask(5));
System.out.println(invoke);
}

}

class MyTask extends RecursiveTask<Integer> {

private int n;

public MyTask(int n) {
this.n = n;
}

@Override
protected Integer compute() {
if(n==1){
return 1;
}
MyTask myTask=new MyTask(n-1);
myTask.fork(); //让一个线程去执行该任务
int result=n+myTask.join(); //获取任务结果
return result;
}
}

image-20230916101000704

JUC

AQS

全称是AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架

特点:

用state.属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁

  • ​ getState -获取state 状态

    • setState -设置state 状态

    • compareAndSetState -乐观锁机制设置state状态

    • 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源

  • 提供了基于FIFO的等待队列,类似于Monitor的 EntryList

  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于Monitor的 WaitSet

它的子类要实现这样的一些方法(方法会默认抛出UnsupportedOperationException)

  • tryAcquire
  • tryRelease
  • tryAcquireShared
  • tryReleaseShared
  • isHeldExclusively

ReentrantLock的非公平锁实现

当没有竞争时:image-20230918095541888

当竞争出现时,Thread-1执行tryAcquire方法,但State已经是1了,失败

image-20230918095641765

Thread-1 在请求锁时:

  1. CAS尝试将NonfirSync的state变为1,但结果失败
  2. 进入tryAcquire逻辑,这时,state已经是1,结果仍然失败
  3. 接下来进入addWaiter逻辑,构造Node队列
    1. 图中黄色三角表示该Node的waitStatus状态,其中0为默认正常状态
    2. Node的创建是懒惰的
    3. 其中第一个Node 被称为Dummy(哑元)或哨兵,用来站位,并不关联线程

第一次尝试失败后

image-20230918100226984

在此有多个线程经历上述过程竞争失败,变成这个样子

image-20230918103946189

Thread-0 释放锁,进入tryRelease流程,如果成功

  • 设置exclusiveOwnerThread为null
  • state=0

image-20230918105249146

当前队列不为null,并且head的waitStatus=-1,进入unparkSuccesor流程

找到队列中离head最近的一个Node((没取消的),unpark恢复其运行,本例中即为Thread-1回到 Thread-1的acquireQueued 流程

image-20230918110031401

如果枷锁成功(没有竞争),会设置

  • exclusiveOwnerThread为Thread-1,state=1
  • head指向刚刚Thread-1所在的Node,该Node清空Thread
  • 原本的head因为从链表断开,可被垃圾回收

如果这时候么有其他线程来竞争(非公平锁的体现),这时Thread-4来了

image-20230918110243032

此时很不巧,Thread-4跑来了(主打的就是一个 插队)

如果不巧又被Thread-4占了先

  • Thread-4被设置为exclusiveOwnerThread,state = 1

  • Thread-1再次进入acquireQueued流程,获取锁失败,重新进入park阻塞