首先我们回顾一下上一章节引起线程不安全的原因
本质原因:线程在系统中的调度是无序的/随机的(抢占式执行)
1.抢占式执行
2.多个线程修改同一个变量.
一个线程修改一个变量=>安全
多个线程读取同一个变量=>安全
多个线程修改不同的变量=>安全
3.修改操作,不是原子的.(最小不可分割的单位)
例如:对一个变量进行自增操作可分为3步:1.load 2.add 3.save
其中一个操作对应单个CPU指令,是原子的.如果跟上述自增这个操作,对应三步,也就对应多个CPU指令,大概率就不是原子的.
4.内存可见性(本章内容进行讲解)
5.指令重排序(本章内容进行讲解)
目录
1. 解决线程抢占式执行 -- 加锁
如何进行加锁呢?
Synchronized的用法(其他)
2. 内存可见性
3. 指令重排序
1. 解决线程抢占式执行 -- 加锁
我们学过的join不能防止线程抢占执行吗?
这个思想是一个办法,不过如果这么搞,就不需要多线程了,直接一个线程串行执行。
多线程的初心:进行并发编程,更好地利用多核CPU.
那么如何保证自增这个操作,是一个原子的呢?--->加锁
举例:
生活中常见的例,去公共厕所.
上厕所,打开门进去,把门锁了。上完厕所,解锁,打开门离开.
锁的核心操作有两个
1.加锁
2.解锁
一旦某个线程加锁了之后,其他线程也想加锁,就不能直接加上了,就需要阻塞等待,一直等到拿到锁的线程释放锁了为止。
记得,线程调度,是抢占式执行的
当1号释放锁之后,等待的2和3和4,谁能抢先一步拿到锁,那是不确定的了。图中就是3号老铁抢到了.
此处的“抢占式执行”导致了线程之间的调度是“随机”的。
如何进行加锁呢?
synchronnized是java中的关键字,直接使用这个关键字来实现加锁效果.
具体如下图所示
package threading;
class Counter{
private int count=0;
public void add(){
synchronized (this){//加锁
count++;
}
}
public int get(){
return count;
}
}
public class ThreadDemo10 {
public static void main(String[] args) throws InterruptedException{
Counter counter=new Counter();
//搞两个线程,两个线程分别对这个counter自增5w次
Thread t1=new Thread(()->{
for (int i = 0; i <50000 ; i++) {
counter.add();
}
});
t1.start();
Thread t2=new Thread(()-> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
}
}
运行结果:
此时来说明一个点:
join是完全让两个线程变成串行的,而加锁是将两个线程的一小部分变成串行,而不加锁的部分还是并发执行的.
加锁之前:
在上述代码中,一个线程做的工作大概是这些:
1.创建i
2.判定i<50000
3.调用add
4.count++
5.add返回
6.i++
其中只有虽然都是并行的,但是因为自增操作不是原子性的,导致线程之间出现抢占式执行.
加锁之后:
其中只有count++是串行的,(抢到锁的先自增完,剩下的再自增)剩下的12356两个线程仍然是并发的。
在保证线程安全的前提下,同时还能让代码跑的更快一些,更好地利用下多核cpu。
无论如何,加锁都可能导致阻塞。代码阻塞,对于程序的效率肯定还是会有影响的。此处虽然是加了锁,比不加锁要慢些,肯定是比串行快,比不加锁算的准。
Synchronized的用法(其他)
1.直接修饰普通成员方法 ==> 以this为锁对象进行加锁
2.修饰静态成员方法 ==> 以类对象为锁对象
等价于下面==>
2. 内存可见性
首先给出一个结论:
所谓的内存可见性就是多线程环境下,编译器对于代码优化,产生了误判,从而引起了bug,进一步导致了我们代码的bug。
下面给出一个例子
package threading;
import java.util.Scanner;
public class ThreadDemo11 {
public static int flag=0;
public static void main(String[] args) {
Thread t1=new Thread(()->{
while (flag==0){
}
System.out.println("循环结束!t1结束!");
});
Thread t2=new Thread(()->{
Scanner scanner=new Scanner(System.in);
System.out.println("请输入一个整数");
flag=scanner.nextInt();
});
t1.start();
t2.start();
}
}
上述代码:我们创建了一个新的线程,里面写了一个循环,我们期待通过t2线程改变标志位,使得标志位变为非0,然后终止t1线程的循环.
运行代码,当我们多次输入1的时候,线程t1循环没有终止.
出现的原因:
t1的这个循环,步骤是这样的,load从内存中读取数据到寄存器,cmp比较寄存器的值是否为0,
此时load的开销非常的大,要一直重复上述操作.读取内存虽然比读硬盘来的快,但是读寄存器,比读内存又要快。此时,编译器就做了一个非常大胆的操作,把load就给优化掉了。只有第一次执行load才真正的执行了。后续循环都只cmp,不load(相当于是复用之前寄存器中的load过的值).这是编译器优化的手段,是一个非常普遍的事情,能智能地调整你的代码执行逻辑,保证程序结果不变地前提下,语句变化,通过一些列操作,让整个程序执行的效率大大提升。编译器对于“程序结果不变”单线程下判定是非常准确的。但是多线程不一定,可能导致调整后,效率提高,结果变了。
那么我们如何针对,这个操作进行修改呢?
1.可以让读寄存器的这个速度稍微慢下来,此时编译器就不会进行优化,也就会每次进行重新load这个操作,那么就会读取到真正的修改后标志位的值.
public class ThreadDemo12 {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while (flag == 0){
try {
Thread.sleep(10);
//加了sleep就让循环执行的很慢,编译器就不会进行优化.load
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("循环结束!t1结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
运行结果:我们可以看到,我们成功的将线程t1的循环停止下来了.
那么除了上述操作,我们还可以使用volatile关键字来进行修改.
被volatile修饰的变量,此时编译器就会禁止上述优化,能够保证每次都是从内存重新读取数据。
注意:
3. 指令重排序
这就是volatile的另一个作用了,防止指令重排序
什么事指令重排序呢?
指令重排序:也是编译器优化的策略,调整了代码执行的顺序,让程序更高效。前提也是保证整体逻辑不变。谈到优化,都要保证调整之后的结果和之前是不变得。单线程下容易保证,多线程就不好说了。
举例:
就拿房子装修来说:
A买了一个新房子(精装修)
B买了一个新房子(毛坯房)
那么A这个过程就是
1.交钱 2.房地产装修 3.交付钥匙
那么B这个过程就是
1.交钱 3.交付钥匙 2.房地产装修
最后AB都拿到了一样的房子(假设装修队是一样的),但是这个过程是不一样的.
上述伪代码
t1中的语句大体可以分为三个操作:
1.申请内存空间——交钱
2.调用构造方法(初始化内存的数据)——装修
3.把对象的引用赋值给s(内存地址的赋值)——拿到钥匙
如果是单线程环境,此处就可以指令重排序:
1肯定先执行,2和3谁先执行,谁后执行,都可以。
那么如果两个线程按照两种不同的方式执行呢?
如果t1按照 1 3 2的顺序执行,当t1执行完1 3 之后,即将执行2的时候,t2开始执行。由于t1的3已经执行过了,这个引用已经非空了。t2开始调用s.learn()。但是由于t1还没有初始化,learn的结果是什么不知道了,(也就是相当于A拿到了毛坯房的钥匙,这不就坏了吗),就出现了bug。
这个代码难以演示,因为大部分情况是正确的。
上述情况,不好演示,因为大部分是正确的,但是也会发生,那么使用volatile关键字修饰这个对象进行修饰,就会保证不会受到指令重排序的影响.