多线程带来的风险——线程安全
一、何为线程安全
? 编写多线程代码的时候,如果当前代码中因为多线程随机的调度顺序,导致程序出现了BUG,就称之为“线程不安全”,如果我们自己写的多线程代码,不管系统按照啥样的随机情况来调度,也不会导致出现 BUG,就称之为“线程安全”。
这里的多线程安全跟黑客无关emmmm…,黑客一般都是跟 网络安全 挂钩的。
- 实现一个经典线程不安全的案例,两个线程进行变量累加
class Count {
public int count = 0;
public void increase() {
count++;
}
}
public class Demo11 {
private static Count count = new Count();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.count);
}
}
这里相加的结果应该是 10w,但是运行的结果大概率不是10w,而是在[5w,10w]之间,10w大概率很少出现。显然这就是一个bug,这种bug是因为线程调度的随机性导致的。
那么在这个过程中 count++ 到底干了给啥???
1.1 线程不安全的原因
count++ 操作其实是三个步骤:
1、把内存 中的值,读到 CPU 的寄存器中 (load)
2、把寄存器中的 0 给进行 +1 操作 (add)
3、把寄存器中的 1 给写回到 内存中 (save)
其中这也是CPU的三条指令。
如果是两个线程,同时操作这个count,此时由于线程之间的随机调度的过程就可能产生不同的结果。
按照上面图形的顺序才是应该正确执行步骤,但是线程调度是随机的。所以就会发生各种的情况。
这些都是可能出现的情况,但是不止这三种,这些情况都是线程不安全的!!!
由此可见,这里情况是两个线程分别自增1,一共自增2次,但是内存还是1,两个线程分别都读到了0,接下来分别自增,得到的都是1,再往内存里写的时候,就都是1,这就是明显的bug。所以这里的线程调度是不安全的。
在操作系统中,调度线程过程中是随机的,就是使t1,t2和内存这三个操作,之间穿插多种情况。我这上面画的几种都是可能出现的情况,当然还有很多的情况我就不一一画出来了,并且那种情况各自出现多少次,这些都是随机的!
只有两种情况是正确的:
有图观察可得:上述串行执行的情况,出现的次数,就直接决定了最终的执行结果。
极端情况,如果5w次循环,都是出现串行的情况,最终结果就是 10w
极端清况,如果5w次循环,一次串行的情况都不出,此时最终结果就是5w
所谓的极端情况,也是小概率事件。
综上所示:就是修改了共享数据,上述代码不安全中,就涉及到了多个线程针对 count 变量来修改,而此时 count 也是一个多个线程共享的数据。在共享的数据中就会出现多种随机情况,就会导致线程不安全。
? 正如每个人都会有隐私,想象成一段代码是一个房间,这个房间没有锁,那么任何人都可以进入,假设这是一个女孩子的房间,那么这个时候一个男孩子进入了这个女孩子的房间,这样女孩子的隐私就会被侵犯,这种是万万不可的(排除这两人是夫妻hhh…) 。
二、多线程带来的风险——线程安全
2.1、 原子性
什么是原子性??
刚才男孩闯入女孩的房间就不具备原子性,那么给房间加一把锁,女孩进入房间把门锁上,其他人进不来,这样就保证了这段代码的原子性。
也就是说在一个操作中要么全做完,中途是不能暂停或者再次调度,要么就等另外一个操作执行完了,再进行;只要一个完整的操作不被 打扰调度暂停 ,那么这就是原子性。
这种现象也可以叫做同步互斥,表示操作是互相排斥的。
2.1.1如何让线程安全?
? 如何让线程安全??根据上面的例子就是给这个房间加锁 !!
? 通过加锁的操作,就是把上述 “无序” 的过程 给变成 “有序” ,把上述 的 “随机” 变成 “确定”。
2.1.2、 synchronized (这里简单了解下,后续祥细说)
java 给线程加锁的方案有多种,其中最常用的方案,就是 synchronized (这个也是java中内置的关键字)。
中文翻译为同步,也可以理解为“互斥”。
class Count {
public int count = 0;
synchronized public void increase() {
count++;
}
}
这时候在 increase ()方法中加上 synchronized 关键字之后,相当于 进入increase()方法,就加锁,出了这个方法 就解锁。
那么 synchronized 关键字是如何解决线程安全的问题??
由上图可知:
基本操作就是先等一个线程执行完,解锁之后,在进行下一个线程执行。可以理解成排队上厕所,厕所里面有人,门是锁着的下一个人拉不开门,只能等厕所里面的人上完厕所解锁出来,下一个人才能去上厕所。
这就也相当于是多个线程在修改一个变量。如果是一个线程修改一个变量,可行;如果是两个线程读这个变量,可行;如果是两个线程修改两个变量,可行;(两个变量相当于两个不同的空间)。
总结:加锁 (synchronized),进入 synchronized 修饰的方法中,就会先加锁,出了方法就会解锁,如果当前有线程占用了这把锁,有其他的线程,那么其他的线程尝试占用这把锁,此时就会出现阻塞等待。
????提出疑问?
实现并发编程,本来就是提高效率的,但是加锁过后,执行结果对了,但是并发性就低了,速度效率也降下来了。
但是做成多线程这个东西还是有意义的,两个并发线程,可能各自要完成的任务有很多,也有不少工作能够并行进行的,整体来说,多线程还是有意义的。
2.2、内存可见性 ☆
public class Demo12 {
private static int flg = 0;
public static void main(String[] args) {
Thread t = new Thread(()->{
while(flg == 0) {
}
System.out.println("t线程结束");
});
t.start();
Scanner scan = new Scanner(System.in);
System.out.println("请输入一个数");
flg = scan.nextInt();
System.out.println("main 线程结束");
}
}
按照普通人的逻辑,这段代码在用户输入一个数字时,这个数就会赋值给flg,flg!=0,从而跳出循环。打印“t线程结束”。但是真正运行的Consequences 并不是这样。
这个场景就是设置一个变量,一个线程无限循环读取这个变量的值,另一个线程会在一定时间之后,修改这个变量的值。但是这里的 t 线程并没有读到修改后变量的值。这种问题就是 内存可见性 。
这里就要补充一个概念,java编译器中的优化功能:
程序猿在编译器中写的代码,编译器并不会逐字翻译,编译器会保证原有逻辑不变的情况下,动态调整要执行的指令内容,这个调整过程,是需要保证原有逻辑不变的,从而这种调整就会提高程序运行的效率。
可惜😥😥😥 得是在多线程的场景下,编译器的判定可能就会存在误差,优化的操作就可能会影响原有的逻辑
编译器优化功能不仅是java,许多主流的语言也是会有编译器优化的功能,因为开发编译器的大佬并不信任我们能写出高效的代码emmmm… 所以让编译器来动态修改我们写的代码,这种优化就会让程序快得非常多,可能是倍数的 promote。
这样的优化也将导致了后面进行 变量 修改的时候 t 线程感知不到,所以就会一直认为 flg == 0 ,然后一直循环,t线程就不会退出。
所谓的内存可见性就是:一个线程改了内存里面的值,但是另一个线程看不见你改的值。
load读的是内存,后面的比较就是在寄存里面比较,而内存的速度要比寄存器的速度要慢 3~4 个数量级。
2.2.1、内存可见性的解决方案。(volatile)
解决方案有两种:
1、使用 synchronized ,加上 synchronized,编译器就会禁止在 synchronized 内部的代码产生上述的优化。
2、还可以使用另一个关键字,volatile.
volatile,用这个关键字修饰对应的变量就行,有了这个关键字,编译器在进行优化的时候,识别到了这个关键字,就知道会禁止进行上述的 读内存 的优化,会保证每次都重新从内存读。
这两个方法,保证了内存可见性,禁止了编译器自己相关的优化。
Thread t = new Thread(()->{
while(true) {
synchronized(Demo12.class) {
if (flg != 0) {
break;
}
}
}
System.out.println("t线程结束");
});
t.start();
private static volatile int flg = 0;
2.3、指令重排序
也是跟编译器优化存在关联(一种优化手段)。
触发指令重排的前提也是要保证代码原逻辑不变;指令重排,就是保证原有逻辑不变,调整了程序指令的执行顺序,从而提高效率。
单线程环境下,这里的判定比较准,
如果是多线程环境下,这里的判定就不太准了
这上面的图是单线程的情况,但是有多个线程的时候,就会判断优化失误;
解决办法也是使用关键字 synchronized ,编译器对于 synchronized 内部的代码非常的谨慎,不会随便乱优化。
三、synchronized 具体使用方法 ☆
三个方面起到的效果:
1、互斥 (核心),也就是阻塞等待
2、保证内存可见性
3、禁止指令重排序
2,3 都是提醒编译器能够优化的谨慎一点
具体的使用方法:
1、在一个普通方法上使用synchronized,进入方法是加锁,出方法就是解锁,锁对象相当于this。
2、加到一个代码块,需要手动的指定一个 “锁对象”。
? 锁对象可以手动指定锁对象 this,也可以指定其他对象作为锁对象;java中,任何一个继承自 Object 类的对象,都可以作为锁对象。(synchronized 加锁操作,本质上是在操作 Object 对象头中的一个标志位)
3、加到一个 static 方法上,此时相当于指定了当前的 类对象,为锁对象。
类对象里面包含了这个类中的一些关键信息,这些关键信息就支撑了java的反射机制,类对象 和 普通对象一样,也是可以作为被加锁的对象。
4、两个线程针对同一个对象加锁,才会产生竞争;两个线程针对不同对象加锁,不会产生竞争。
可以看成上厕所的时候厕所里面人满了,需要排队等待,直到在锁里面的人解锁出来下一个人才能进去,此时就形成了锁锁竞争;如果厕所里面还有空的厕所间,下一个人就直接进入空的厕所间,他们就不会产生竞争关系;在同一个空间里面就是竞争,在不同空间就没有竞争。
在加锁的时候,必须要明确当前是针对啥来上锁的!!!只有说针对指定的对象上锁之后,此时多个线程尝试操作相关的对象才会产生竞争关系!!!
3.1 可重入锁
是 synchronized 重要特性!
如果 synchronized 没有 可重入锁,那么就会出现 “死锁” 的情况。
class Count {
public int count = 0;
synchronized public void increase() {
synchronized (this) {
count++;
}
}
}
可能会经常写出这种情况,就是针对同一个对象 Count ,加锁了两次,没有可重入的功能,就会出现 “死锁”。
按照之前的理解,第二次加锁的时候是会阻塞等待的,等待第一次加锁把锁释放到了,才能获取到第二把锁,但是释放第一把锁使用该线程里面的代码完成的,这个时候就僵住了。
为了避免这种情况,java大佬们就设计出来 可重入锁,针对同一把锁可以多次加锁,不会有负面效果。
锁中会持有两个信息:
1、当前这个锁被那个线程给持有;
2、当前这个锁被加锁了几次;
当 t 线程加锁后,当前这个锁就是 t 持有的,后续再次进行加锁操作,并不会真正的加锁,而只是修改计数(1->2);
后续往下执行的时候,出了 synchronized 代码块,就触发一次解锁,这里的解锁也不会真正的解锁,而是计数 -1 操作;
在外层方法执行完了之后,再次解锁,再次计数 -1 ,计数减成0了,才真正的进行了解锁。
但是在操作系统原生提供加锁相关的api中就是不可重入锁了。
3.2 死锁
死锁不仅仅只会出现我们上述的情况,针对同一把锁加锁多次,也会有另外一种经典的情况。(哲学家们就餐的问题)
1个线程,1把锁;
2个线程,2把锁;
N个线程,M把锁;
我这里是举了个差不多的栗子:
这种僵住的情况也就是 “死锁”,N个线程,M把锁;这也是一种“死锁”的情况。
因为多线程有很多的陷阱,同时也引出了面试中的常见问题:某个集合类是否是线程安全的??
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBulider
以上属于线程不安全!! 注意这些对象在多线程中谨慎使用,尤其是一个对象被多个线程修改的时候。
- Vector
- HashTable
- ConcurrentHashMap
- StringBuffer
- String (注意)
以上都是线程安全的,前面都带有 synchronized 关键字,更加放心的在多线程环境下使用;注意这里的 String 比较特殊,因为 String 是不可变对象,不能修改,因此就不能在多线程中修改同一个 String 了。
????提问:
synchronized 和 volatile 两个有啥区别?
synchronized:原子性,内存可见性,指令重排序
volatile:内存可见性。
3.3 JMM
JMM => java Memory Model java 存储模型(内存模型)。
JMM 就是 CPU 的寄存器,以及 内存 之间这样的一套模型,java只是取了一个抽象的名字 JMM
JMM中把CPU的寄存器这部分存储称为 “工作内存”(work memory),memory理解成存储更好,因为是CPU的寄存器。
JMM中把正常的内存称之为 ”主内存“(main memory),这也是理解成存储会更好;
在代码中执行一些操作,例如前面的 count++,就是把 主内存 中的数据,拷贝到 工作内存 ,然后 工作内存自增,再次拷贝到 主内存 中。这里只是说法概念不一样,但是逻辑是一样的;JMM相当于概念性的东西。
面试中问到了 内存可见性 问题,可以从两个维度来回答 :一、CPU/ 内存 这个角度来;二、也可以从JMM 的 主内存/ 工作内存 这个角度来回答。
3.3.1缓存
缓存是属于CPU上面的:
1、缓存的容量比内存小 1-2 个数量级
2、缓存的容量比寄存器要大,
3、缓存的速度比内存快
分为L1(一级内存),L2,L3(三级内存),想对缓存而言,L1最小最快,L3最大最慢。
为什么会出现缓存??
CPU的工艺和性能要比内存更好,假如CPU每秒能读10条指令,但是内存每秒是能读1条指令。而CPU是要通过内存才能读取指令的,由于内存的效率低,就拉低了CPU的性能,每秒只能读一条指令。因此CPU就大才小用了!怎么解决??
1、让内存的工艺跟CPU差不多,但是代码的后果就是内存的价格成本极高,可以理解是8内存100万…😕😕😕,pass
2、就是在CPU和内存中间有一个缓存(Cache),而 Cache 的速度比内存快,所以,CPU在请求指令的时候同时请求的是 Cache和内存,由于 Cache 比内存快,响应的结果就会先给CPU,而 Cache 里面的东西就是先预先下载好CPU常用的指令,响应的速度更快。
缓存顾名思义也是日常生活中的缓存,其实就是把CPU常用的指令缓存下来从而提升速度,尽可能以小的代价,实现尽可能高的性价比。当然容量大,指令的命中率就越高,这里的命中率始终指的是 Cache。
3.4 wait和notify
多线程的调度过程是充满随机性的,系统源码是改变不了的,所以系统层面上不能解决问题。
但是在实际开发中我们希望合理的协调多线程之间的先后顺序。
通过 wait和notify 机制,来对多线程之间的执行顺序,做出一定的控制。
当某个线程调用 wait 之后,就会阻塞等待。
直到其他某个线程调用 notify 把这个线程唤醒为止。
public class Demo14 {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
synchronized (o){
System.out.println("等待之前");
o.wait();
System.out.println("等待之后");
}
}
}
上面的这个代码 o线程 调用了 wait 就会一直等。直到notify唤醒才会继续进行。
这里的必须要加 synchronized ,不然后报错, 出现Exception in thread "main" java.lang.IllegalMonitorStateException 的错误,Monitor -> 就是监视器,synchronized也可以叫做监视器锁。
wait 这个方法里面会做三件事情:
1、先针对 o 解锁。(应该加了锁才能解锁)
2、进行等待(等待通知的到来)
3、当通知到来之后,就会被唤醒,同时尝试重新获取到锁,然后再继续执行。
正因为 wait 里面做了这几件事情,所以 wait 需要搭配 synchronized 来使用。
public class Demo15 {
private static Object loker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread waiter = new Thread(()->{
while(true) {
synchronized (loker){
System.out.println("wait 开始");
try {
loker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 结束");
}
}
});
waiter.start();
Thread.sleep(3000);
Thread notifier = new Thread(()->{
synchronized(loker) {
System.out.println("notify 之前");
loker.notify();
System.out.println("notify 之后");
}
});
notifier.start();
}
}
运行结果:
wait 开始
notify 之前
notify 之后
wait 结束
wait 开始
从运行结果可以看出,wait开始,进行阻塞等待,notify唤醒,线程继续,后面又循环,线程开始,直到有 notify 唤醒为止线程才继续。
notify 也是 Object 类的方法,那个对象调用了wait,就需要那个对象调用 notify 来唤醒。
o1.wait(), 就需要o1.notify 来唤醒。
o1.wait(),使用 o2.notify,没效果。
notify 同样搭配 synchronized 来使用;如果多个线程都在等待,调用一次 notify ,只能唤醒其中的一个线程,具体唤醒那个线程就是系统随机的了,如果没有任何线程等待,调用notify,不会有副作用。
notify 也是用必要加锁的,notify 本质上也是在针对 locker 对象里面的对象头进行了修改状态,需要保证先加上锁,在进行其他修改。(属于java的特殊要求)
总结:有了 wait 和 notify 机制,就可以针对多个线程之间的顺序进行一定的修改了。
除此之外,java中还有个 notifyAll 的操作,一下子全部唤醒,唤醒之后,这些线程再尝试竞争这同一个锁,唤醒全部,这些线程尝试竞争锁,然后按照竞争成功的顺序,依次往下执行。notify的话就是唤醒一个,其他线程仍然在wait中阻塞。
从而也引出面试题:wait 和 sleep 的对比
一个是线程之间的通信,一个是让线程阻塞一段时间;
总结:
1.wait 需要搭配 synchronized 使用,sleep不需要;
2、wait 是Object 的方法 sleep 是 Thread 的静态方法;
这篇帖子重点介绍了线程安全的问题,面试高频考点,是整个多线程中最重要的关键点!!!也是概念居多(八股文),代码较少,下一篇会写关于多线程的案例,代码会 enhance 点。
铁汁们,觉得笔者写的不错的可以点个赞哟?🧡💛💚💙💜🤎🖤🤍💟,收藏关注呗,你们支持就是我写博客最大的动力!!!!
|