线程安全——synchronized和volatile

文章目录

  • 线程安全
    • 一、什么是线程安全问题
    • 二、线程不安全实例
    • 三、线程不安全原因以解决办法
      • 1.原子性
        • 1.1 定义
        • 1.2 不安全的原因
        • 1.3 synchronized关键词
        • 1.4 synchronized特性
        • 1.5 synchronized使用
        • 1.6 修改示例
      • 2.内存可见性
        • 1.1 示例
        • 1.2 不安全的原因
        • 1.3 volatile关键词
        • 1.4 修改示例
      • 3 指令重排序
        • 1.1 作用
        • 1.2 示例
    • 总结

线程安全

一、什么是线程安全问题

首先我们需要明白操作系统中线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时线程的执行顺序是不确定的,有一些代码执行顺序不同不影响程序运行的结果,但也有一些代码执行顺序发生改变了重写的运行结果会受影响,这就造成程序会出现bug,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题。

二、线程不安全实例

public class ThreadDemo9 {
    public static void main(String[] args) throws InterruptedException {
        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();

        t1.join();
        t2.join();

        System.out.println(counter.get());
    }
}


 class  Counter {
    private int count = 0;

     public void add() {
        count++;
    }

    public int get() {
        return count;
    }
}

预期结果:100000

实际结果:
线程安全——synchronized和volatile_第1张图片

为什么两个线程分别自增5w次,而结果不是10w呢?

三、线程不安全原因以解决办法

1.原子性

1.1 定义

一组操作(一行或多行代码)是不可拆分的最小执行单位,就表示这组操作是具有原子性

多个线程多次的并发并行的对一个共享变量操作时,该操作就不具有原子性

1.2 不安全的原因

counter.add() 我们可以把这句话拆分成3个操作:

  • load 从内存中读取值到寄存器
  • add 进行自增操作
  • save 从寄存器写回内存

由于线程执行的随机性,这三步操作可能存在交叉执行(一个正在add,一个正在load)

如果我们通过代码让三步操作固定在一起,就能解决问题了

1.3 synchronized关键词

你在房间中使用ATM时,你把门锁住,其他人就不能进来

你使用完房间后,解锁,这个时候其他人都能进入房间

线程安全——synchronized和volatile_第2张图片

在java中最常用的加锁操作就是使用synchronized关键字进行加锁

1.4 synchronized特性

互斥

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
  • 没有抢到锁的线程阻塞等待,参与下一次的锁竞争
  • 锁竞争
    • 两个线程竞争同一把锁, 才会产生阻塞等待
    • 两个线程竞争不同把锁, 不会产生阻塞等待

刷新内存

synchronized的工作过程:

  • 获得互斥锁
  • 从主存拷贝最新的变量到工作内存
  • 对变量执行操作
  • 将修改后的共享变量的值刷新到主存
  • 释放互斥锁

可重入

synchronized是可重入锁
同一个线程可以多次申请成功一个对象锁

某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁

1.5 synchronized使用
  • 修饰普通方法

public class SynchronizedDemo {

  	synchronized  public void methond() {
  	
  	}

}

  • 修饰代码块

public class SynchronizedDemo {

​ public void method() {

  	  synchronized (this) {  
  
  	  }

​ }

}

这里的this可以换成任意一个Object类对象,效果和修饰普通效果一样

  • 修饰静态方法

public class SynchronizedDemo {

 synchronized public static void method() {
  
  }

}

  • 修饰代码块

public class SynchronizedDemo {

   public void method() {
  
  	   synchronized (SynchronizedDemo.class) {
  
  	  }

​ }

}

  • 锁对象

谁调用被synchronized修饰的方法或者类,谁就是锁对象

线程安全——synchronized和volatile_第3张图片

这里的counter都在调用add方法,counter就是锁对象

1.6 修改示例
public void add() {
	synchronized(this){
   			 count++;
		}
}

保证counter.add()中的三步操作同时执行,最终结果才能正确。

join与加锁的区别:

  • join:让某一个线程完整执行完,完全串行,效率低
  • 加锁:某一部分串行,其他是并行
  • 保证线程安全的前提下,让效率更高。

2.内存可见性

1.1 示例
    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();
    }

通过执行结果可以看到,我们输入一个数字后,程序并不会结束。

1.2 不安全的原因

该程序执行的主要两个步骤

  • load 从内存中读取数据到寄存器
  • cmp 比较寄存器的值是否是0

由于while循环体为空,执行速度很快,远远超过比较的速度,这样就导致每次读出来的值都是0,所以编译器就主动进行优化,认为load读出来只会是0,就导致只会进行一次读数据,后面一直进行比较。这也是为什么即使我们输入了非0的数,也不能停止程序

编译器优化

在保证程序结果不变的前提下(多线程不一定),通过加减语句等操作让程序效率提升。

要想解决这个问题,我们只需要让编译器不主动的优化

1.3 volatile关键词

让编译器不优化其修饰的变量,每次都从内存重新读取

  • volatile不能保证原子性
  • 适用一个线程读、一个线程写。

线程安全——synchronized和volatile_第4张图片

工作内存:寄存器+缓存

拓展:

  • CPU缓存:读取速度介于读寄存器和内存之间

  • cpu读数据顺序:寄存器=》缓存1=》缓存2=》缓存3=》内存

1.4 修改示例
public class ThreadDemo14 {
    volatile 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();
    }
}

3 指令重排序

1.1 作用

指令重排序:在保证整体逻辑不变的前提下,调整代码执行顺序,提升效率。

1.2 示例

线程安全——synchronized和volatile_第5张图片

调整后:

线程安全——synchronized和volatile_第6张图片

总结

线程不安全的原因:

线程是抢占式的执行,线程间的调度充满了随机性
多个线程对同一个变量进行修改操作
对变量的操作不是原子性的
内存可见性导致的线程安全
指令重排序也会影响线程安全

你可能感兴趣的:(java,开发语言)