【JavaEE】线程安全问题


哈!抓住你了!

别走了啦~
【JavaEE】线程安全问题_第1张图片

文章目录

  • JavaEE & 线程安全问题
    • 1. 线程安全的一个经典例子
      • 1.1 初步代码设计
      • 1.2 原因
        • 1.2.1 count++ 的“非原子性”
        • 1.2.2 线程的调度是无序的
    • 2. synchronized锁
      • 2.1 代码演示 + 解析
    • 3. 内存可见性引发的线程不安全
      • 3.1 内存可见性bug例子
      • 3.2 线程不安全原因
      • 3.3 处理方式
    • 4. 指令重排序引起的线程不安全
      • 4.1 处理方法

JavaEE & 线程安全问题

  • 重点重点重点~

  • 线程安全问题,就是某个代码在多线程环境下执行,会出bug,即线程不安全,即不符合预期~

  • 本质的本质,是因为线程之间的调度顺序是不确定的~

1. 线程安全的一个经典例子

  1. 现在我们想要完成一个操作,就是将一个计数器count进行【++】
  2. 而这个操作,我们不仅仅在一个线程中去执行
  3. 而是在多个线程里去执行
  4. 这样子做会发生什么效果呢?

1.1 初步代码设计

首先我们可以将计数器做成对象,或者用“全局性质”的静态变量~

  1. 两个自己创建的线程和静态变量count
public class Test {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;

            }
        });

        thread1.start(); //启动
        thread2.start();

        thread1.join(); //等线程结束~
        thread2.join();

        System.out.println(count);
    }
}
  • 效果是这样的~

【JavaEE】线程安全问题_第2张图片

  • 多次运行的结果好像不尽人意呀,如果5000-> 50000 ,现象会更明显~

【JavaEE】线程安全问题_第3张图片

  • 对,没错,答案大概率不是100000,总共加了100000次了,并且还是静态变量,为什么不对呢~
  1. 一个自己创造的线程和main线程 + 计数器对象
class Counter {
    private int count = 0;
    public void add() {
        count++;
    }
    public int get() {
        return count;
    }
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.add();
            }
        });
        for (int i = 0; i < 5000; i++) {
            counter.add();
        }
        thread1.start();
        thread1.join();
        System.out.println(counter.get());
    }
}
  • 奇怪的是,为什么这两个线程反而结果是对的~

【JavaEE】线程安全问题_第4张图片

  • thread1.start();
    System.out.println(counter.get());
    thread1.join();
    System.out.println(counter.get());
    
  • 【JavaEE】线程安全问题_第5张图片

  • 没错,因为只加5000次,main线程一下子就跑完了

  • 你只要加多点,肯定会出错~

  • 不过你较真的话,有百分之0.00000001的可能成功~

  • 其实什么方式,只要次数低点,都很有可能结果正确

1.2 原因

  • count++语句的非原子性

    • “原子”在那个时期,是无法分割的意思
    • 所以“原子性”代表【一条语句/一段语句】是不可分割的整体
  • 对于一条语句

    • 它可能分为多条汇编代码
  • 对于一段语句

    • 它本身就是可分割的
    • 而在MySQL里的事务,是具有”原子性“的
1.2.1 count++ 的“非原子性”

【JavaEE】线程安全问题_第6张图片

  • count++在汇编中分为三个原子步骤
    • load,加载,即寄存器记录count值
    • add,增加值,即寄存器中的值进行增加操作
    • save,赋值,即将寄存器中值赋给内存中的count

【JavaEE】线程安全问题_第7张图片

1.2.2 线程的调度是无序的
  • count++分为3个原子步骤
  • 而CPU的核心在执行的时候是按照原子步骤走的~
  • 那么线程调度的时候,CPU的核心并不会一次性将count++这条语句执行完,而是并发或者并行的分次执行完~
  1. 并发

【JavaEE】线程安全问题_第8张图片

  1. 并行

【JavaEE】线程安全问题_第9张图片

  • 无论是哪一种,其根本原因,都是,在寄存器未把值赋给count的时候,此时的count就被其他寄存器记录了,导致,两次count++,实际只加了一次
  • 要知道,别的线程再load的时候,是读内存的count值,如果此时count并没被赋值(save),就会浪费一次count++

【JavaEE】线程安全问题_第10张图片

  • 当然,还是可能会出现极端情况的,并不代表最近结果是在【50000,100000】
  • 而是【1,100000】(1就很极端极端极端了~)
  • 因为可能多条count++,进行影响

【JavaEE】线程安全问题_第11张图片

2. synchronized锁

  • 在上面的经典案例中我们可以得知,线程的不安全的原因
    • 抢占式执行(罪魁祸首)
    • 多个线程修改同一变量
  • 如果多个线程修改不同变量,是安全的
  • 如果多个线程读取同一/不同变量,是安全的
  • 一个线程修改同一变量,是安全的
  • 而多个线程修改同一变量,是不安全的

那么怎么解决呢?

  • Java中有一个关键字synchronized
    • 这个关键字,可以让一个对象,在被一个线程修改(任何方面的变化)的时候,多个线程谁先“开始”修改,即抢到了“锁”,那么其他线程,就会进入阻塞等待的状态,“BOLOCKED”
    • 即该修改语句以及其后的语句,均被设置为“原子”整体~
    • 而该线程执行完后,释放锁,并且跟其他线程参与**“抢锁环节”**

2.1 代码演示 + 解析

class Counter {
    private int count = 0;
    public void add() {
        synchronized (this) {
            count++;
        }
    }
    public int get() {
        return count;
    }
}

public class Test1 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        for (int i = 0; i < 50000; i++) {
            counter.add();

        }

        thread1.start();
        thread1.join();
        System.out.println(counter.get());

    }
}
  • 解决问题~

【JavaEE】线程安全问题_第12张图片

  • 解释
    【JavaEE】线程安全问题_第13张图片

【JavaEE】线程安全问题_第14张图片

  • 这只是其中一种写法,其实还有以下几种写法~

【JavaEE】线程安全问题_第15张图片

  1. 传对象
    • 修改此对象时候,需要抢到锁才行

【JavaEE】线程安全问题_第16张图片

  1. 用synchronized修饰方法
    • 这个方法与锁(this)是一样的
    • 整个方法为锁范围

【JavaEE】线程安全问题_第17张图片

  1. 传类对象(.class)
  • 这种方式适用于静态方法和普通方法
    • 想修改此类的实例,就要抢到锁才行

【JavaEE】线程安全问题_第18张图片

  1. 用synchronized修饰静态方法
    • 在整个方法为锁范围
    • 跟传类对象是一样的

【JavaEE】线程安全问题_第19张图片

  1. 在对静态变量count进行count++操作的时候,count为基本数据类型,无法成为锁对象,就可以这么做~
  • Object类作为锁对象时,则代表,进入这个代码块,从始至终,必然需要抢到锁才行,没抢到锁的线程,阻塞等待~

  • 不同于其他方式,这个并没有明确的锁对象
    * 是个同步锁

    • 其他方式,则是此对象仅仅可以被抢到锁的线程修改,执行完花括号的语句,才释放锁。
      • 此过程中,此对象是绝对不能在其他线程被修改的,要修改必须阻塞等待,争取抢锁

注意:不同锁对象的话,就相当于修改不同变量,并不会引起阻塞,不会锁竞争,那就跟没加锁一样,反而增加了开销


补充说明:

  • 类对象的含义:

    1. 类名
    2. 类的属性,属性名,类型,权限…
    3. 类的方法,方法名,参数列表(签名),返回类型,权限…
    4. 类继承哪些类
    5. 类实现哪些接口
  • 类对象相当于“对象的图纸”

    • 这个图纸反映了对象的“轮廓”
    • 这样就可以用Java反射的API去做一些事情了~
      • 当然,反射是非常规的语法
      • 能不用,就不用~
      • 忘了也无所谓~
  • 类对象还能直接调用静态方法/静态属性

    • 因为静态属性/静态方法,不依托于对象存在,类被加载即存在
    • 所以,类对象就可以调用这些静态的东西了~

3. 内存可见性引发的线程不安全

  • 什么是内存可见性问题呢?
  • 先看一个bug

3.1 内存可见性bug例子

public class Test {

    public static int flag;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while(flag == 0) {

            }
            System.out.println("thread线程over");
        });
        Thread thread1 = new Thread(() -> {
            try {
                Thread.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = 1;
        });
        thread.start();
        thread1.start();
    }
}
  • 我们想的是:flag在2毫秒后,被置为1,即非0,线程thread的循环停止,程序进而结束~

但是实际情况是:

【JavaEE】线程安全问题_第20张图片

  • 2ms是可能让结果正确的,只是我这里没展示
    • 而这是因为flag在线程thread循环判断前就被置为1了,那么就没法进入循环,紧接着程序就结束了~
  • 1ms太短了,导致结果是正确的

3.2 线程不安全原因

【JavaEE】线程安全问题_第21张图片

  • 内存可见性
    • 本质上是编译器对代码的优化
    • 为了增加程序运行效率
    • 因为读内存比cmp操作慢太多了~
  • 所以编译器做出了一个大胆的操作
    • 既然每次循环都要用load
    • load的结果又都是一样的
    • 那么我咋不把这个load省略掉
      • 更详细点来说,就是不读主内存
        • main memory
      • 只读工作内存:CPU寄存器
        • work memory

        • 读寄存器/缓存 >> 读内存 >> 读硬件盘

        • cpu缓存:

          • 【JavaEE】线程安全问题_第22张图片
        • cpu查数据:

          1. 看看寄存器有没有
          2. 看看L1有没有
          3. 看看L2有没有
          4. 看看L3有没有
          5. 看看内存有没有…
        • 这样子安排,是为了提高速度~

      • 这一理论来自,Java官方的JMM(Java Memory Model)
  • 所以此线程的flag的内存可见性,第一次读应用于以后~
    • 因为此线程只知道,该线程内,并没有任何对flag有修改的意图
      • 单线程,这个判断,很准确~
    • 所以,编译器就误认为flag不会被更改-
    • 并且把load给优化掉了

3.3 处理方式

  • 我们需要编译器在遇到这种情况的时候,不要优化
  1. 让循环速度降低,加入sleep(xxx),这样load的优化就几乎没用,所以编译器就不会优化~
    【JavaEE】线程安全问题_第23张图片

  2. 使用volatile关键字~

  • 用volatile修饰的变量,并不会被系统优化~
    • 保证每次都是有load操作的,不会被省略~

【JavaEE】线程安全问题_第24张图片

  • volatile的用法很简单
  • volatile不保证“原子性”
    • volatile适用于一个线程写,一个线程读的情况~
    • 防止代码都的时候被优化而已~
    • 保证内存可见性~
  • synchronized则是适用于多个线程写,保证“原子性”

4. 指令重排序引起的线程不安全

  • 这个也是编译器优化的策略~

    • 即调整指令执行的顺序,让程序更加高效
    • 同样的,这个行为只能保证此改动本线程内是无影响
    • 对于其他线程的介入,不知道~
  • 同样的,volatile关键字一样也能取消此优化~
    【JavaEE】线程安全问题_第25张图片

  • 如果外加一个线程,此线程判断a是否为null,如果不是,调用a的一个方法~

【JavaEE】线程安全问题_第26张图片

  • 如果,此时先执行第三步,则会导致错误!因为构造方法未被调用,可想而知有多严重
  • 将相当于去买房子,装修再住,住了再装修,最终结果都一样~

在这里,我不给代码演示了,因为不好演示,并且错误率不高,但是也不能说没有~

4.1 处理方法

  1. volatile取消优化
    • 只需要用volatile去修饰a,就可以在创建引用的时候,禁止指令重排序

【JavaEE】线程安全问题_第27张图片

  1. 指令重排序其实也就是因为原子性没被保证
    • 所以可以用synchronized去环绕代码块~

【JavaEE】线程安全问题_第28张图片


文章到此结束!谢谢观看
可以叫我 小马,我可能写的不好或者有错误,但是一起加油鸭

你可能感兴趣的:(JavaEE,java-ee,java,jvm)