多线程篇三

如笔者理解有误,欢迎交流指正⭐

线程安全

划重点!!!

什么是线程安全

想对线程安全做一个具体清晰的解释很困难,为什么这么说?当然是具体情况具体分析.(就像很多人都喜欢吃面条 有的人喜欢吃炸酱 有的人喜欢吃刀削【其他不具体拓展 因为饿了w】可以具体细分)

但是!我们可以这样认为 如果多线程环境下代码运行的结果是符合我们预期的 ,即使在单线程环境应得的结果,则说明这个线程是安全的.

产生线程安全的原因

抢占式执行(系统内核)

上我们熟悉的实例代码

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
// 线程安全
public class Demo13 {
// 此处定义一个 int 类型的变量
private static int count = 0;

public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Object locker2 = new Object();

Thread t1 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});

t1.start();
t2.start();

t1.join();
t2.join();

// 预期结果应该是 10w
System.out.println("count: " + count);
}
}

我们的预期情况是输出100000 但是实际运行结果如下
![img](C:\Users\lenovo\Documents\Tencent Files\3023536144\nt_qq\nt_data\Pic\2024-09\Ori\6060fd35ca0792320d83c150d9658e61.png)
img
不是预期值且可以发现多次运行的结果并不相同
这是为什么?

1
2
3
  for (int i = 0; i < 50000; i++) {
count++;
}

这部分代码就是典型的线程安全问题.
t1和t2这两个单独的线程在单独执行过程中,毫无疑问没有任何问题.
但是t1和t2两个线程并发执行上述循环,会出现逻辑上的问题.

1
2
3
4
5
t1.start();
t2.start();

t1.join();
t2.join();

这样是t1和t2同时执行,多个线程执行上述代码时,由于线程之间的调度顺序是”随机”的,在某些调度顺序下会出现逻辑问题.

站在CPU的角度上,count++是由CPU的三个指令实现的.
1.load 把数据从内存中读取到CPU中
2.add 把寄存器中的数据进行+1
3.save 把寄存器中的数据保存到内存中
在上述3个步骤执行过程中,其实有无数种排列组合的方式.
img

img


上述代码执行完之后发现了bug
两个线程本应该是分别自增1次,但2个线程在自增过程中并没有累加而是各自独立运行,故预期得到2,实际只有1.【确实够”随机” 笑】
注意 我们得到的”随机值”一定小于100000但也存在小于50000的值(如果t1自增一次的过程中 t2自增多次【如2次】相当于自增了3次 只有一次生效了)
img
怎么样保持t1执行完再执行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
// 线程安全
public class Demo13 {
// 此处定义一个 int 类型的变量
private static int count = 0;

public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Object locker2 = new Object();

Thread t1 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进行自增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});

// 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的 count 就是个 0
t1.start();
t1.join();

t2.start();
t2.join();

// 预期结果应该是 10w
System.out.println("count: " + count);
}
}
同一个变量被两个线程都修改

解决方法:

1.一个线程对同一个变量进行修改 ok
2.两个线程针对不同变量进行修改
3.两个线程针对一个变量读取

非原子的修改操作
什么是原子性

比如我(线程A)登录一个手游(一段代码),在我未退出游戏时,我们的游戏好友(线程B)也可以登录游戏组队打本.(打断我在游戏中的隐私)这就是不具备原子性
听起来是不是和抢占式很像?真聪明hh
如果线程不是“抢占”的就算没有原子性

一个java语句不一定是原子的,也不一定只是一条指令

上述count++先进行 了读取再进行修改操作
若一段逻辑中存在要根据一定条件决定是否修改也存在类似的问题

解决方法:想办法让count++一次性被被CPU完成上述3个步骤

内存可见性问题

可见性指的是一个线程共享变量值的修改能被其他线程看到
Java内存模型(JMM)Java虚拟机规范了Java内存模型
目的是屏蔽掉各种硬件和操作系统的内存访问差异 以实现Java程序在各种平台下都能达到一致的并发效果
img
线程之间共享变量存储在主内存中
每一个线程都有自己的”工作内存”
当线程要读取一个共享变量的时候 会先把变量从主内存拷贝到工作内存中 再从工作内存读取数据
当线程要修改一个共享变量的时候 会先修改工作内存中的副本 再同步回主内存
**工作内存像是一个”枢纽” 其实就是CPU的寄存器和高速缓存 **

指令重排序问题

比如我们用代码实现
1.去茶话弄取餐
2.去菜鸟拿快递
3.去买曹氏

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,不止是规规矩矩按1->2->3执行,而是可以按1->3->2执行(其他顺序也可以)这就叫指令重排序.

如何解决上述问问题呢?
简单暴力:加锁!

解决线程安全问题的方法

synchronized关键字
特性一互斥

synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象的synchronized
synchronized用的锁存在于Java对象里.

1
2
3
synchronized () {
count++;
}

()中需要表示一个用来加锁的对象.对象是什么不重要,只是通过这个对象区分两个线程是都在竞争一个同一个锁

如果两个线程是针对同一个对象加锁 就会出现锁竞争

如果不是争对同一个对象进行加锁就是正常的并发执行

1
2
3
4
5
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}

这样写两个线程的执行顺序就会相互影响 可以理解为**”并发执行”转变为”串行执行”**(两个线程同时尝试对一个对象加锁,出现锁竞争,一个线程能拿到线程袭击执行,另一个线程只能阻塞等待.等前一个线程释放锁之后,才机会拿到锁继续执行.)
img

特性二刷新内存

synchronized工作过程:
1.获得互斥锁
2.从主存拷贝变量的最新副本到工作内存中
3.执行代码
4.将更改后的共享变量的值刷新到主内存
5.释放互斥锁

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
public class Demo15 {
private static Object locker = new Object();

public static void func1() {
synchronized (locker) {
func2();
}
}

public static void func2() {
func3();
}

public static void func3() {
func4();
}

public static void func4() {
synchronized (locker) {

}
}


public static void main(String[] args) {

}
}

tips

无论有多少层都是要在最外层才能释放锁
可以引用计数器来确定有多少层或者记录是否真的释放锁成功(计数器值为0即可)

死锁

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

public class Lock {
public static Object locker = new Object();
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized (locker) {
synchronized (locker) {
count++;
}
}
}
});

Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});

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

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
34
35
36
37
38
39

public class Lock2 {
public static Object A = new Object(), B = new Object();

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (A) {
//sleep一下,是给t2时间,让t2也能拿到B
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//尝试获取B,并没有释放A
synchronized (B) {
System.out.println("t1拿到了两把锁");
}
}
});

Thread t2 = new Thread(() -> {
synchronized (B) {
//sleep一下,是给t1时间,让t1能拿到A
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//尝试获取A,并没有释放B
synchronized (A) {
System.out.println("t2 拿到了两把锁");
}
}
});

t1.start();
t2.start();
}
}

出现死锁是因为两个线程都卡在了获取对方已经得到锁的位置.
n个线程得到m把锁
OS课上老师讲过的哲学家就餐问题

死锁的四个必要条件

1.互斥使用
一个线程拿到锁A另一个线程也想拿到锁A,就需要阻塞等待.

2.不可抢占
一个线程拿到锁之后其他线程想拿到这个锁只能等待线程A释放这个锁

3.请求保持
一个线程拿到锁A之后,在获取A的基础上还想获取锁B.(吃着碗里的看着锅里的)

4.循环等待
两个或多个线程都想互相获取对方的锁,都进入等待队列.(最容易被破坏的 但修改加锁顺序可避免 )

解决死锁的方案也就是破坏上述四个条件任何一个即可
1)引入一个额外的锁
2)去掉一个线程
3)引入计数器
4)引入加锁规则【比较推荐 普世性高->sychronized】

synchronized的使用方法
1)修饰代码块

锁任意对象

1
2
3
4
5
6
7
8
public class Demo {
private Object locker = new Object();

public void method() {
Synchronized (locker) {

}
}

锁任意对象

1
2
3
4
5
6
7
public class Demo {

public void method() {
Synchronized (this) {

}
}
2)修饰实例方法

注意 锁对象是什么不重要 重要的是两个线程中的对象是否是一个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Counter {
public int count;

synchronized public void increase() {
//此时使用this作为锁对象
count++;
}
public void increase() {
synchronized(this) {
count++;
}
}
}

3)修饰静态方法

针对类对象加锁

1
2
3
4
5
6
7
8
9
synchronized public static void increase1() {

}

public static void increase2() {
synchronized(Counter.class) {
//Couter.class ->类对象
}
}

注意 类对象在一个java进程中是唯一的(代码中写了一个Counter的类对象,不会有多个)

Java标准库中的新城安全机制

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
如:

1
ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder

有一些是线程安全的.使用了一些锁机制来控制.
如:

1
Vector(不推荐使用),HashTable(不推荐使用),ConcurrentHashMap,StringBuffer(不推荐使用)

还有的是没有加锁,但因为不涉及修改的特殊类,也是线程安全的.

1
String