文章目录
- 线程安全性
- 写在前面
- 1.线程不安全的四种原因
- 2. 解决办法
- 2.1 synchronized关键字
- 2.2 Volatile关键字
线程安全性
写在前面
什么是线程安全?
线程安全就是保证多个线程同时对某一对象或资源进行操作时不会出错。 比如当我们购物时,两个用户同时下单将商品加入购物车,此时两个用户可以看作两个线程,在线程安全的情况下,两个用户同时下单购买时,我们商品总额会减少两个。线程不安全就是指多个线程执行结果不符合预期的情况。
1.线程不安全的四种原因
- 抢占式执行,多个线程争抢着执行任务,而CPU会随机调度此时线程执行的顺序不可控,就会导致线程不安全。
- 多个线程同时修改同一个变量,也就是上述举例对商品总额进行修改时,会出现线程不安全的情况。
- 线程修改的操作不是原子的,比如我们java语句:num++,此时对应的CPU指令是三条:
- 从内存把数据读到 CPU,load
- 进⾏数据更新,++
- 把数据写回到 CPU,save
此时当我们正在进行num++时,中途其他线程插入,此时由于线程的抢占式调度,插入的线程就可能中途也执行并对num进行修改,此时num的值就不符合预期。
代码演示:
我们通过两个线程对num++,各自增5w次,看最后结果是否是10w。
package threading;
/**
* @author zq
* 线程不安全实例
*/
class Counter{
private int count =0;
public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class ThreadDemo8 {
public static void main(String[] args) throws InterruptedException {
//创建两个线程对cout进行自增五万次
Counter counter =new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
t1.start();
t2.start();
//等待两个进程结束后main进程打印结果
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
运行结果: 不符合预期,并且每次结果不一致。
- 内存可见性,可见性是指一个线程对共享变量值的修改,能够及时的被其他线程所看到。
举个例子:当我们有两个线程,t1线程频繁的读取一个共享变量的值,t2对共享变量修改,并写入主内存。由于编译器优化,不去读取主内存中的变量的值,直接读取他工作内存的值,导致修改不能被识别到,就会出现线程不安全。
代码演示: 我们创建一个线程循环读入flag的值,当为0是循环结束,另一个线程修改flag的值:
package threading;
import java.util.Scanner;
/**
* @author zq
* 演示由于内存可见性引起的线程不安全
*/
public class ThreadDemo9 {
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();
}
}
运行结果: 由于循环读入效率低,编译器优化导致我们输入不为0的数仍然不结束线程
- 指令重排序 :JVM会对我们的指令自动进行他认为的优化,在单线程下没有问题,但是多线程中会对我们的线程安全造成影响。
举个例子: 对于Student类,在t1线程我们new一个Student类,在t2线程对Student类对象进行判空,并调用其中的方法。
t1:
s = new Student();
t2:
if(s!=null)
s.learn();
我们new一个对象时,主要分为三部分操作:
1.申请内存空间
2.调用构造方法(初始化数据)
3.把对象的引用赋值给s
由于编译器会自己优化指令,所以2,3,的顺序是可以改变的,而此时如果t2
线程来进行对s的读取,如果此时按1、3、2顺序执行,我们s中就不会有learn()方法。此时就出现了线程不安全的情况。
2. 解决办法
注意:
对于CPU的随机调度,抢占式执行我们是改变不了的。
2.1 synchronized关键字
synchronized加锁之后,可以保证一个变量只被一个线程修改,同时也能保证操作的原子性。
synchronized的特性:
- 互斥。synchronized 会起到互斥效果,当两个线程对同一个对象进行加锁时就会出现锁竞争,然后阻塞等待,只有当获得锁的线程释放锁才能再进行加锁。
进入 synchronized 修饰的代码块, 相当于加锁。
退出 synchronized 修饰的代码块, 相当于解锁。 - 刷新内存,通过synchronized的工作过程就可以保证内存可见性,此处由于网上资料没有确定的证据,在笔者这存疑。
synchronized 的⼯作过程:
1.获得互斥锁
2.从主内存拷贝变量的最新副本到⼯作的内存
3.执行代码
4.将更改后的共享变量的值刷新到主内存
5.释放互斥锁 - 可重入。在同一个线程中如果synchronized对同一对象进行两次加锁,此时不会发生锁竞争,从而阻塞等待。
synchronized的使用
- 修饰普通方法:此时锁对象就是SynchronizedDemo的对象。默认为this,谁调用就是对谁加锁,作用域为这个方法。
public class SynchronizedDemo {
public synchronized void methond() {
}
}
- 修饰静态方法:锁对象就是SynchronizedDemo类对象。
public class SynchronizedDemo {
public synchronized static void method() {
}
}
解释说明: 我们的.java源代码文件,经过编译之后,就会生成二进制字节码文件,此时jvm执行这个文件得先将文件内容读取到内存中(类加载),而类对象就是来表示.class文件内容的,描述了类方方面面的信息。例如类名、类的属性、方法、权限等等。
- 修饰代码块:锁对象是自己指定的,只要是Object的实例都可以作为锁对象,基本数据类型除外。比如以下代码对当前对象加锁:this表示Synchronized的一个实例。 如果是对类加锁:可以写为SynchronizedDemo.class
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
synchronized的锁机制
这个可以参考我的另一篇博客Java中的锁策略以及死锁的成因
2.2 Volatile关键字
volatile关键字可以帮我们解决内存可见性和指令重排序问题,但是解决不了原子性问题。
== 当volatile修饰变量时==
(1)写入过程:
- 改变线程⼯作内存中volatile修饰的变量副本的值。
- 将改变后的副本的值从⼯作内存刷新到主内存。
(2)读取过程:
- 从主内存中读取volatile修饰变量的最新值到线程的⼯作内存中,
- 从⼯作内存中读取volatile修饰变量的副本。
此时我们就可以发现,volatile会强制我们每次读取主内存中的变量的值,这时候就能保证内存可见性,以免直接读入工作内存中变量的值出现线程不安全问题。
同样的我们volatile也可以禁止编译器进行优化,解决指令重排序问题。