文章目录
前言
在上一篇博客中,我们介绍了线程,知道它属于更轻量级的进程,这主要在于,一个进程里面的多个线程共用一份进程的资源(但每个线程也有自己的私有资源(如寄存器,栈)),因为共享资源,那必然会出现资源抢占问题,进而引出了线程安全问题,接下来将好好介绍这个
一、什么是线程安全问题(线程冲突)
1.线程安全问题
举个例子:比如在旅游景点有一个公共卫生间(一个进程),这里有多个马桶(这些马桶就是共享资源),这个时候有个老铁突然肚子不舒服,想上厕所,来到这里,发现有一个没关门(其实全都有人,那个门坏了,关不了),于是冲进去,结果里面的人就大叫(有人啊!!!)(要是大晚上,厕所灯还坏了,各位可以想想会发生多严重的问题),这两个人发生的事情就是线程安全问题,这个问题非常严重,会导致预期结果与实际不符合,比如下面这个代码
class Counter{
public int count;
public void increase(){ //在public前加synchronized (加锁)
count++;
}
}
public class Demo6 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t.start();
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t2.start();
t.join();
t2.join();
System.out.println(counter.count);
}
}
这段代码的作用是用两个线程把一个变量加到100000,大家可以猜猜结果是多少
这就是线程安全问题引起的与预期效果不符(我们可以加锁解决(给方法加锁解决))
2.测试案例详解
在解决这个问题之前,我们需要知道一个变量++会发生什么事情,如下图
通过这个图,我们可以知道完成一个++需要三个步骤
下面这样执行是不会出问题的,结果会加到2
但是另一种情况就会出问题,如下图,结果只能会为1,但是加了两次
这就是CPU随机调度线程导致的原因,在第二张图,t1线程刚load,然后CPU就记录上下文,然后执行t2线程,然后结果从0加到1;然后再回来执行t1,发现load已经执行,于是执行add,save写回(把1写回),这就导致执行两次,但结果只加了一次。
二、线程安全问题产生原因有哪些
1)抢占式执行,调度过程随机(也是万恶之源,无法解决)
2)多个线程同时修改同一个变量(可以适当调整代码结构,避免这种情况)
3)针对变量的操作,不是原子的(加锁,synchronized)
4)内存可见性,一个线程频繁读,一个线程写(使用volatile)
5)指令重排序(使用synchronized加锁或者volatile禁止指令重排序)
1.原子性
我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
小tip:由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区
有时也把这个现象叫做同步互斥,表示操作是互相排斥的
一条 java 语句不一定是原子的,也不一定只是一条指令(例如++操作,内部三条指令构成)
比如上面我们说到的变量++,其实是由三步操作组成的:
- 从内存把数据读到 CPU
- 进行数据更新
- 把数据写回到 CPU
不保证原子性会给多线程带来什么问题
如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的
这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.
2.内存可见性
可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
- 线程之间的共享变量存在 主内存 (实际内存).
- 每一个线程都有自己的 “工作内存” (CPU寄存器,高速缓存) .
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
- 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
此时引入了两个问题:
为啥要整这么多内存?
为啥要这么麻烦的拷来拷去?
- 为啥整这么多内存?
实际并没有这么多 “内存”. 这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法. 所谓的 “主内存” 才是真正硬件角度的
“内存”. 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存.
- 为啥要这么麻烦的拷来拷去?
因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).
比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果
只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问
内存了. 效率就大大提高了.
那么接下来问题又来了, 既然访问寄存器速度这么快, 还要内存干啥??
答案就是一个字: 贵
重点:这里会出现一个线程安全问题:那就是内存可见性问题,这个问题来自编译器与JAVA内存模型的存在,假如我们要读取10000次内存中的变量,因为java内存模型的原因,每次最终都会在工作内存中读到这个值,于是编译器就想优化一下(它以为你短期内不会中途修改),于是这10000次读取(速度很快)就第一次从内存中读,其它9999次读工作内存,如果这10000次读取中,其他线程修改了这个共享变量,这个时候读到的数据就不是最新的了,这就导致与预期不符(内存可见性问题)(可以用关键字volatile禁止编译器优化,保证内存可见性(synchronized也可(后面会讲到原因)))
总结下:
速度:CPU访问寄存器>内存>硬盘
价格:CPU访问寄存器>内存>硬盘
3.指令重排序
什么是代码重排序
比如有段代码是这样的
- 去前台取下 U 盘,2.去教室写 10 分钟作业 3. 去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序
编译器对于指令重排序的前提是 “保持逻辑不发生变化”. 这一点在单线程环境下比较容易判断, 但
是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代
码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价.
指令重排序通常它需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果;
- 存在数据依赖关系的不允许重排序
tip:截止jdk1.8, Java 里只有 volatile 变量是能实现禁止指令重排的
4.多线程对共享变量进行修改
正如上面提到的++到100000;假如我们是对两个变量分别加到50000,即不会出现线程安全问题 测试
public class Demo4 {
public static long a = 0;
public static long b = 0;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
a++;
}
}
});
Thread t1 = new Thread(new Runnable() {
public static final int a = 0;
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
b++;
}
}
});
t.start();
t1.start();
t.join();
t1.join();
System.out.println("a:"+a +" b:"+b);
}
}
结果
所以我们应该尽量避免多线程操作共享变量,否则就加锁处理
三、synchronized关键字
1.互斥
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
进入 synchronized 修饰的代码块, 相当于 加锁
退出 synchronized 修饰的代码块, 相当于 解锁
synchronized用的锁是存在Java对象头里的。
可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕
所的 “有人/无人”).
如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队(阻塞等待)
理解 “阻塞等待”.
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝
试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的
线程,(并不是立即就可以获得这个锁,需要等操作系统唤醒(调度的一部分工作)) 再来获取到这个锁(但是在阻塞队列中的等待的锁并不遵循先来后到(也被称为公平锁))
2.刷新内存(保证内存可见性)
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
主要由于第四步的存在,所以 synchronized 也能保证内存可见性.
3.可重入锁
定义:同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入,如果不会死锁,就是可重入的
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第
二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无
法进行解锁操作. 这时候就会 死锁.(这就是不可重入锁)
为了避免死锁,所以java中synchronized(修饰的对象、方法、代码块)和ReentrantLock都是可重入锁(所以上面这个图不会出现死锁)
重入锁的实现原理
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息:
1.如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取
到锁, 并让计数器自增.
2.解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
tip:synchronized不是锁,synchronized是java里的一个关键字,可以用来给对象和方法或者代码块加锁
4.synchronized不能防止指令重排序
synchronized 虽不能禁止指令重排,但能保证有序性
这个有序性是相对语义来看的,线程与线程间,每一个 synchronized 块可以看成是一个原子操作,它保证每个时刻只有一个线程执行同步代码,它可以解决JAVA内存模型中工作内存和主内存同步延迟现象引发的无序
synchronized与volatile区别(一个能禁止,一个不能禁止的原因)(底层原因)
synchronized 是因为块与块之间看起来是原子操作,块与块之间有序可见
volatile 是在底层通过内存屏障防止指令重排的,变量前后之间的指令与指令之间有序可见
总结:synchronized 块里的非原子操作依旧可能发生指令重排(所以不能)
四、volatile关键字
1.保证内存可见性
如下面代码
public class Demo7 {
public static int a = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
while (a==0){ //优化只从寄存器读,不再去内存中读(优化) (当读的消耗很小(比如下面休眠3秒就不必要优化,就不会出现内存可见性问题))
}
System.out.println("t1停止");
});
t1.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入");
a = scanner.nextInt();
t1.join();
System.out.println("Main结束");
}
}
当我们对变量a进行修改的时候,会发现while循环并不会停止,但是条件已经不满足了,这是为什么呢(这就是内存可见性导致的问题),上述代码执行结果如下
应该结束的while循环并没有停止(与预期不符),具体原因可以看上面讲述的内存可见性问题产生的原因,这个时候只要在变量a前面加上volatile关键字即可解决
扩展:这主要是编译器优化的原因,对于这段代码,我们还可以用其他办法解决,如下代码,代码中
有注释
public class Demo7 {
public static int a = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
while (a==0){ //优化只从寄存器读,不再去内存中读(优化) (**当读的消耗很小(比如下面休眠3秒就不必要优化,就不会出现内存可见性问题)**)
System.out.println("hello");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1停止");
});
t1.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入");
a = scanner.nextInt();
t1.join();
System.out.println("Main结束");
}
}
结果如下
2.禁止指令重排序
我们这里拿单例模式中实例化一个对象举例:
Singleton instence = new Singleton(); //保证对象实例化正确
-
给 singleton 分配内存
-
调用 Singleton 的构造函数来初始化成员变量
-
将 instence 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)
volatile禁止重排序,只能1->2->3,如果1->3->2在3到2之间有线程判断instence是否为空就会出错,其对象是还没有进行初始化(属性赋值之类),所以会出现问题
volatile 禁止指令重排底层原理:
先了解下概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作执行的顺序性
- 保证某些变量的内存可见性(这也是volatile保证内存可见性的原因)
通过这个CPU指令,插入内存屏障就能禁止在内存屏障前后的指令执行重排序优化。内存屏障的另外一个作用是强制刷出各种CPU的缓存数据。(保证内存可见性)
五、volatile和synchronized区别
- volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;
synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。等释放锁(然后刷新内存) - volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类
- volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性(原子性加锁)
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。(加锁可能导致死锁)
- volatile标记的变量不会被编译器优化(禁止优化);synchronized标记的变量可以被编译器优化
总结
通过这篇博客,对于线程安全问题有了一定的了解,知道了为什么会出现线程安全问题,知道了产生线程安全的原因有哪些,例如原子性,内存可见性,指令重排序等,对synchronized有了一定的认识,也知道了volatile的作用,实现原理,知道了内存屏障(实现禁止指令重排序原理),总结了synchronized和volatile的区别。加油!!!