文章目录
一、线程安全
线程安全是多线程中最核心的问题,也是最难的一个问题,这里面存在这很多不确定因素,所有多线程并不好驾驭。
先来看一个列子,我们希望两个线程同时对一个变量各自自增5W次.
public class TestThread {
static class Count {
int count = 0;
public void count() {
this.count++;
}
}
public static void main(String[] args) {
Count count = new Count();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
count.count();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
count.count();
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count.count);
}
}
这个代码每次的运行结果都不一样,并不是预期的 10W,那么这是为什么呢?
这大概率是和并发执行相关,由于多线程并发执行,导致代码中出现 BUG,这样的情况就称为 线程不安全。
我们来粗略分析一下这段代码的执行过程
把count++的详细过程分成 3个步骤
1.把 内存 中的值读取到CPU中 ( LOAD)
2.执行 ++ 操作 (ADD)
3.把CPU的值写回到内存中 (SAVE)
下面我画的是我们预期的结果
1.线程1先把 count的值读取到第一个CPU上
2.接着在CPU上执行 ++ 操作
3.再写回到内存中
依照时间线,再取执行线程二的这三项操作,那么线程1和线程2就各自对 count变量执行了++操作
那么这两个线程真的是按照刚才画的的这个顺序执行的吗?
这肯定是不确定的,操作系统在调度线程的时候,是采用 抢占式执行的方式,
某个线程啥时候能上CPU执行,啥时候会切换出CPU式完全不确定的
而且另一个方面,两个线程在两个不同的CPU上也可以完全并行执行
因此两个线程执行的具体顺序,是完全不可预期的。
比如下面这种情况,也是有可能发生的。
这两个线程同时执行自增 5W次,不知到出现了多少次线程不安全的问题,所以每次运行的结果都是不可预期的。
二、 产生线程不安全的原因
1. 线程之间都是抢占式执行的,这也是根本的原因
抢占式执行,导致两个线程里面操作的先后顺序无法确定,这是操作系统内核实现的,我们也无法改变。这样的随机性,就是导致线程安全问题的根本所在。
2. 多个线程修改同一个变量
这和我们代码的写法密切相关
一个线程修改同一个变量,没有线程安全问题
多个线程读取同一个变量,也没有线程安全问题,读取只是单纯把数据从内存中放到CPU上,不管怎么读内存的数据式不会发生改变的
多个线程修改不同的变量,也没有线程安全问题
所以为了避免线程安全问题,就可以尝试变换代码的组织形式,达到一个线程只修改一个变量,但不是每个场景都可以这样。
3.原子性
像 ++ 这样的操作,本质上式三个步骤,是一个非原子的操作
像 = 操作,本质上就是一个步骤,认为是一个原子的操作
举个例子:
把代码想象成一个房间,每个线程就是要进入房间的人,如果没有任何限制,A进入房间后,还没有出来,B也是可以进入房间的,打扰到A。这就不具备原子性。如果给门加个锁,就可以保证原子性了。
同样的我们的 ++ 操作也是可以加锁的,后面会讲到通过 synchronized 关键字加锁。
4.内存可见性
这是一个和编译器优化相关的问题,
可见性指: 一个线程对共享变量值的修改,能够及时地被其他线程看到.
再来举一个简单的列子:
线程1:只读取变量
线程2:循环对变量自增
由于编译器的优化,可能把一些中间环节的 SAVE 和 LOAD操作去掉了
此时读的线程可能读到的是未修改过的结果
每次执行自增 都要 LOAD 和 SAVE
因为++是在CPU上执行的,假设 ADD操作比执行LOAD和SAVE操作要快1w倍这个时候,线程2为了能够算的快,于是就偷懒了
为了提高程序的整体效率,于是线程2就会把中间的一些LOAD和SAVE操作省略掉,这个省略操作时编译器(javac)和JAVM(java)综合配合达成的效果
当执行循环++操作时由于编译器的优化省略掉了一些SAVE 和 LOAD操作,当线程1读取值的时候就没有读取到预期的结果
如果只是单线程下,这样的优化,没有任何副作用,如果是多线程下,另外一个线程也尝试读取或者修改这个数据,肯定是会有问题的。
5.指令重排序
这和内存可见性一样也是和编译器优化相关。
编译器会自动调整执行的指令顺序,已达到提高执行效率的效果。
举个例子
假设取超市买菜,要买西红柿、土豆、黄瓜、茄子。如果按照购买清单上的顺序来买肯定要走不少弯路
如果优化了,就不会按照购买清单的顺序购买,但依旧能买到想买的菜。而且时间也更快了。
这种调整优化的前提是保证最终效果是不变的,如果当前的逻辑是只是在当线程上运行。编译器判定是否影响结果,就很容易。
如果当前的逻辑在多线程下运行,线程是抢占式执行的
三、解决线程安全
我们已经知道了线程不安全的原因,那么如何解决这个问题呢?
最普适的方法,就是通过原子性作为切入点来解决
1.synchronized
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
再来看一下 synchronized的特性
1.互斥
我们继续拿文章开头那个两个线程同时对一个变量进行自增的例子来说:
我们用 synchronized 修饰了 count方法后,运行结果就变成我们预期的结果 10w 了。
synchronized的功能本质就是把 并发变成并行,适当牺牲了速度换来了更准确的结果。
这就类似于去 ATM 机取钱,一个人进去了把锁锁上了,后面的人就只能等了
synchronized 除了可以用来修饰方法之外,还可以修饰一个“代码块”
synchronized 如果是修饰代码块的时候,需要显示的在()中指定一个要加锁的对象,
如果是 synchronized 直接修饰的非静态方法,相当于加锁的对象就是this
synchronized
相当于一把锁,当一个线程进入这个方法后这把锁就会锁上。其它线程也行执行这个方法的时候就会放生阻塞等待,只有等这个线程执行完了才能进入这个方法。
2.刷新内存
synchronized 不光能够起到互斥的效果,还能够刷新内存(解决内存可见性的问题)
就比如上面说的一个代码就循环的对一个变量 ++
每次自增都会有
1.把 内存 中的值读取到CPU中 ( LOAD)
2.执行 ++ 操作 (ADD)
3.把CPU的值写回到内存中 (SAVE)
编译器会优化这里的效率,把中间的一些 LOAD 和 SAVE 省略掉
但加上 synchronized 之后,就会禁止上面的优化,保证每次操作的时候都会把数据真的从内存读,也真的写回内存中。
这就说明了:一旦代码中使用了 synchronized ,此时就可能就和 高性能 无关了
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
synchronized用的锁是存在Java对象头里的。
3.可重入
先来看一段代码
这一段代码是什么意思呢?
count方法已经加了一次锁,再进入代码块又加了一次锁。这种操作对于 synchronized来说是没问题的,synchronized在这里进行了特殊处理。
第一次加锁,加锁成功
第二次再尝试针对这个线程加锁的时候,此时对象头的锁标记已经是true,按照咱们之前的理解,线程就要阻塞等待,等待这个锁标记但还是仔细一想,这个锁啥时候能释放?
在这里 synchronized 内部记录了当前这个锁是哪个线程持有的,所以当线程发现这是自己的锁就会直接进代码块执行。
如果是其它语言,这里可能就会出现线程的死锁。
所以可重入就是同一个线程针对同一个锁进行加锁操作,不会出现死锁。
2.volatile
volatile 能保证内存可见性,但是不能保证原子性
volatile的用法比较单一,只能修饰一个具体的属性
此时代码中针对这个属性的读写操作就一定会涉及到内存操作了
一般来说,如果某个变量,在一个线程中读,一个线程中写,这个时候大概率需要使用 volatile
假设我们要通过输入来停止一个线程,如下面代码所示,但是我们输入后线程依旧在运行,并没有打印线程结束
那这是什么原因导致的呢?
因为 t 线程里在快速循环读取 falg 的值(频繁的从内存读数据)
由于这里读的太快了,所以编译器直接进行了优化,并不会每次都从内存里读取 flag 的值,读了一次之后,后续都是直接从 CPU中来读内存的值了
此时此刻,线程1感知不到 flag 内存对应的数据变化
这个也是内存可见性的问题(本质上都是编译器优化带来的问题)
解决办法,就是用 volatile 修饰这个属性,当然也可以用synchronized关键字。
volatile 这里涉及到一个重要的知识点,JMM内存模型
代码中需要读取一个变量的时候,不一定是真的在读内存,可能这个数据已经在CPU或者是
JMM针对计算机的硬件结构,右进行了一层抽象(主要考虑到Java跨平台,要能支持不同的计算)
JMM就把CPU的寄存器称为工作内存(一般不是真正的内存),JMM也把真正的内存称为主内存
三个线程,就有各自的工作内存,每个线程都有自己独立的上下文,独立的上下文就是各自的一组寄存器
CPU在和内存交互的时候,经常会把主内存的内容,拷贝到工作内存中,然后进行操作,写回到主内存。
这个过程中就非常容易出现数据不一致的情况,这种情况在编译器开启优化的时候会特别严重
volatile 或者 svnchornized 就能够强制保证接下来的操作是操作内存
四、wait 和 notify
由于线程之间是抢占式执行的,所以线程执行的先后顺序充满了不确定性。但是我们有希望线程的执行顺序式可以控制的。
那么就用到了 wait 和 notify 方法,注意:它们都是 Object的方法
wait:等待
notify:通知
notifyAll:通知所有线程
要想使用 wait 和 notify ,必须搭配 synchronized
需要先获取到锁,才有资格谈 wait
注意:wait 和 notify 的使用必须是针对同一个对象的对象锁,且要在 synchronized里使用
package safety;
public class ThreadDemo2 {
static class TestWait extends Thread {
private Object obj = null;
TestWait (Object obj) {
this.obj = obj;
}
@Override
public void run() {
//wait针对同一对象锁操作
synchronized (obj) {
System.out.println("wait()开始");
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait()结束");
}
}
}
static class TestNotify implements Runnable {
private Object obj = null;
TestNotify (Object obj) {
this.obj = obj;
}
@Override
public void run() {
//notify针对同一对象锁操作
synchronized (obj) {
System.out.println("notify()开头");
obj.notify();
System.out.println("notify()结束");
}
}
}
public static void main(String[] args) {
//wait()和notify()必须对同一个对象使用
Object obj = new Object();
Thread t1 = new Thread(new TestWait(obj));
Thread t2 = new Thread(new TestNotify(obj));
t1.start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.start();
}
}
wait 方法会做三件事
1.让当前线程阻塞等待(让这个线程的PCB从就绪队列拿到等待队列中),并准备接受通知
2.释放当前锁
3.满足一定的条件被唤醒时,重新尝试获取这个锁
wait 和 notify 都是 Object 的方法
比如线程1中的对象1调用了wait
必须要有个线程2,也调用对象1的 notify,才能唤醒线程1
如果是线程2,调用了对象2的 notfiy,就无法唤醒线程1
关于 notify的使用
1.也要放到 synchronized 中使用
2.notify操作是一次唤醒一个线程,如果有多个线程都在等待中,调用notify相当于随机唤醒了一个,其它线程保持原状
3。调用 notify 这是通知对方被唤醒,但是调用notify 本身的线程并不是立即释放锁,而是要等待当前的 synchronized
代码执行完才能释放锁(注意:notify本身不会释放锁)
4.notify 是一次唤醒一个,notifyAll是一次把所有线程都唤醒了
wait和sleep的区别
这两其实没有明确的关联关系,
1.sleep操作是指定一个固定时间来阻塞等待,wait的话也可以指定时间等待,也可以无限等待
2.wait 唤醒可以通过 notify 或者 interrupt 或者时间到来唤醒,sleep 唤醒通过时间到或者 interrupt 唤醒
3.wait主要的用途就是为了协调线程之间的先后顺序,这样的场景并不适合 sleep。sleep单纯让该线程休眠,并不涉及到多个线程配合
多线程持续跟新中......