【JavaEE初阶】第五节.多线程 ( 基础篇 ) 线程安全问题(上篇)

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

目录

文章目录

前言

一、线程安全的概述

1.1 什么是线程安全问题

1.2 存在线程安全问题的实例

二、线程安全问题及其解决办法 

2.1 案例分析

2.2 造成线程不安全的原因 

2.3 线程加锁操作解决原子性 问题 

      2.3.1 什么是加锁

      2.3.2 使用 synchronized关键字 进行加锁

      2.3.3 synchronized 使用示例 

三、Java标准库里面的线程安全类

总结


前言

今天我们将进入到线程基础篇当中有关线程安全的问题线程安全对于我们学习线程有着非常重要的作用今天我们将通过本节学习能够更好的认识到线程安全以及解决线程安全的问题

就让我们进入到今天的学习当中


一、线程安全的概述

1.1 什么是线程安全问题

线程安全问题 出现的 "罪魁祸首"正是 调度器的 随机调度 / 抢占式执行 这个过程

在随机调度之下多线程程序执行的时候有无数种可能性有无数种可能的排列方式

在这些排列顺序中有的排列方式 逻辑是正确的但是有的排列方式 可能会引出 bug

对于多线程并发时会使程序出现 bug 的代码 称作线程不安全的代码这就是线程安全问题

接下来举出一个典型的例子来观察一番 到底什么是线程安全问题

1.2 存在线程安全问题的实例

创建两个线程让这两个线程 同时并发 对一个变量自增 5w 次最终预期能够一共自增 10w 次

代码示例

package thread;
 
class Counter {
    //用来保存计数的变量
    public int count;
 
    public void increase() {
        count++;
    }
}
 
public class Demo14 {
    public static void main(String[] args) {
        //这个实例用来进行累加
        Counter counter = new Counter();
 
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
 
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count" + counter.count);
    }
}

 运行结果

说明:

很明显我们可以发现 程序运行的结果 都是 小于 10w 次的即便是运行多次结果也都是小于 10w 次;

事实上正确的结果是得到的数字 count 在 5w 到 10w 之间;

这是怎么回事呢 我们接下来慢慢分析;

二、线程安全问题及其解决办法 

2.1 案例分析

按理来说上述实例 运行的结果 count 应该等于 10w

可是 连续运行多次就会发现 每一次运行的结果都不一样但都是小于 10w这是为什么呢

这个就是 线程不安全的问题

原因主要是随机调度的顺序不一样就导致程序运行的结果不一样;

上述的 bug 是怎么形成的呢

这个得需要站在硬件的角度来理解

像 count++ 这一行代码其实对应的是 三个机器指令

  1. 首先 需要从内存中读取数据到 CPU这个指令称为 load指令;
  2. 其次 在 CPU 寄存器中完成加法运算这个指令称为 add指令;
  3. 最后 把寄存器的指令 写回到内存中这个指令称为 save指令;

注意

这个在 JavaEE初阶 的第一篇文章中提到过不清楚的可以跳转过去看一看

这三个步骤如果是在单线程下执行那是没有任何问题的

但是如果是在多线程下执行那就不一定了


现在我们可以以一条时间轴来画一下其中常见的情况

load 是把内存中的数据读到 寄存器里add是在寄存器里面进行加法操作save是把寄存器里面的值放回到内存

情况一

对两个数进行自增操作内存 初始值为 0两个线程进行并发执行进行两次自增

寄存器A 表示 线程1 所用的寄存器寄存器B 表示 线程2 所用的寄存器

通过上述的执行过程我们可以看到两个线程 各自增一次预期 自增两次实际上的结果是 2没有任何问题

看起来是没有任何问题的可是实际情况下 这个可是多线程只是出现无数种情况的其中一种而已只是这种排列方式恰好没有问题其他的排列方式就不一定了 


情况二

对两个数进行自增操作内存 初始值为 0两个线程进行并发执行进行两次自增

寄存器A 表示 线程1 所用的寄存器寄存器B 表示 线程2 所用的寄存器

如上所示明明在内存里面自增了两次但是最终内存的值 仍然是 1

这就是典型的线程不安全导致的 bug


情况三

对两个数进行自增操作内存 初始值为 0两个线程进行并发执行进行两次自增

寄存器A 表示 线程1 所用的寄存器寄存器B 表示 线程2 所用的寄存器

如上所示最终的内存中保存的是 1

这是典型的线程不安全的问题


其他的情况

说明

由于是多线程所以有无数种情况

总之在无数中的排列顺序情况下只有 "先执行完第一个线程再执行完第二个线程" 以及 "先执行完第二个线程"再执行完第一个线程 的这两种情况是没有问题的

剩下的情况全部都是和正确结果不匹配

总结

回到最初的代码程序我们就可以知道

在极端情况下如果所有的执行排列都是 "先执行完第一个线程再执行完第二个线程" 以及 "先执行完第二个线程"那么此时的总和就是 10w

在极端情况下如果所有的执行排列顺序 是不包括这两种情况的其他情况那么此时总和就是 5w

更实际的情况下调度器具体调度出多少种这两种极端的情况我们是无法确定的

因此 最终的结果是 5w ~ 10w

操作系统的随机调度其实不是 "真随机"而是 操作系统内核的调度器调度线程其内部是有一套 逻辑 / 算法来支持这一调度过程

即 每种出现的排列情况下不是均等的所以不可以通过排列组合的情况下算出每种情况 出现的概率的

2.2 造成线程不安全的原因 

一操作系统的 随机调度 / 抢占式执行

这个是 万恶之源、罪魁祸首

这个是 操作系统内核 实现的时候就是这样设计的因此 我们改不了就算可以改得了自己的电脑也改不了其他的人的那么多电脑对此 我们是无能为力的


二多个线程 修改 同一个变量

如果只是一个线程修改变量没有线程安全问题

如果是多个线程读同一个变量也没有线程安全问题

如果是多个线程修改不同的变量还是没有线程安全问题

但是多个线程修改同一个变量那就有了线程安全问题了

所以在写代码的时候我们可以针对这个要点进行控制可以通过调整程序的设计来去规避 多个线程修改同一个变量

但是此时的 "规避方法" 是有适用范围的不是所有的场景都可以规避掉这个得要看具体的场景


三有些修改操作不是 原子的修改更容易触发 线程安全问题

在 MySQL数据库中说过不可拆分的最小单位 就叫做原子

如赋值操作来修改=只对应一条机器指令就是视为原子的

像之前通过 ++操作 来修改对应三条机器指令就不是原子的


四内存可见性 引起的线程安全问题

内存可见性这个就是另外一个场景了一个线程写一个线程读的场景;

这个场景 就特别容易因为 内存可见性 而引发问题;

内存可见性;

线程1进行反复的 读 和 判断 ;

线程2在某个环节下进行修改;

如果是正常的情况下线程1 在读和判断线程2 突然写了一下 => 这是正常的在线程2 写完之后线程1 就能立即读到内存的变化从而让判断出线变化;

但是在程序运行过程中可能会涉及到一个操作 —— "优化" 可能是编译器 javac也可能是 JVM java也可能是操作系统 的行为;

那么 由于 线程1 频繁的进行 load   test 操作就很有可能会被优化成 load   test   test......操作会认为 一直读的都是一样的值所以不需要再读了;

 

 

每次 load操作 都是读内存操作每次 test操作 都是在读寄存器读内存操作 要比 读寄存器操作 慢上几千倍、上万倍;

正是由于 load操作 读的太慢再加上 反复读每一次读到的数据又一样所以 JVM 就做出了这样的优化就不再重复的从内存中读了直接就复用第一次从内存读到寄存器的数据就好了;

那么如果在优化之后线程2 突然又写了一个数据;;

由于 线程1 已经优化成读寄存器了因此 线程2 的修改线程1 感知不到 =>这就叫做 内存可见性问题内存改了但是在 优化 的背景下读不到、看不见了;

所谓优化是指在执行正确的前提下来做出变化 使得性能更优;

一定要保证程序的逻辑是正确的再说效率问题

上述场景的优化在单线程场景下没有问题但是在多线程情况下就可能会出现问题多线程环境太复杂编译器 / JVM / 操作系统 进行优化的时候就可能产生误判;

针对这个问题Java 引入了 volatile关键字让程序猿手动的禁止 编译器 / JVM / 操作系统 对某个变量进行上述优化 


五指令重排序也可能引起线程不安全

        

指令重排序也是 操作系统 / 编译器 / JVM 优化操作

它调整了代码的执行顺序达到加快速度的效果;

举例说明:

比如说张三媳妇 要张三去到超市买一些蔬菜并且给了他一张清单

  1. 西红柿
  2. 鸡蛋
  3. 茄子
  4. 小芹菜

调整顺序后也是符合张三媳妇 对张三的要求买到了四样菜并且效率也是得到了提高;

至于买的过程是什么样子的张三媳妇并不关心;

这个就叫做 指令重排序

指令重排序也会引发线程不安全

此处就容易出现指令重排序引入的问题

2 和 3 的顺序是可以调换的;

在单线程下调换这两的顺序是没有影响的但是如果在多线程条件下那么是会出现 多线程不安全

假设 另一个线程尝试读取 t 的引用如果是按照 2、3的顺序第二个线程读到 t 为 非null 的时候此时 t 就一定是一个有效对象如果是按照 3、2的顺序第二个线程读到 t 为 非null 的时候仍然可能是一个无效对象

总结

线程安全问题出现的五种原因

前三种原因 是更普遍的;

1.系统的随机调度万恶之源、无能为力
2.多个线程同时修改同一个变量部分规避
3.修改操作不是原子的有办法改善的
后两种原因是 编译器 / JVM / 操作系统 搞出的幺蛾子但是 总体上来说还是利大于弊的

4.内存可见性;
5.指令重排序;
编译器 / JVM / 操作系统 误判了导致把不应该优化的地方给优化了逻辑就变了bug 就出现了当然后两种原因 也可以用 volatile关键字 来进行解决

2.3 线程加锁操作解决原子性 问题 

现在先重点来介绍一下 解决线程安全问题出现的第三种原因的方法原子性通过 加锁操作来把一些不是原子的操作打包成一个原子的操作

加锁在 Java 中有很多方式来实现其中最常用的就是 synchronized用法其实也挺简单的我们需要注意的是它的拼写和发音


2.3.1 什么是加锁

举例说明

举个简单明了的例子假设 你要去银行ATM机 取钱我们都知道ATM机 是放在一个单独的小房子里面的每个小房子都有一把锁如果你进去了那么这个锁就会自动的锁起来别人就进不去了除非是 你已经取钱成功了 并且 自己已经出来了下一个人才可以继续使用到 ATM机

取钱成功了说明 取钱的几个步骤是成功了的那么我们希望去 ATM机 取钱的这些步骤是能够一气呵成的如果 不一气呵成万一走的时候忘记啥步骤取钱没有成功大大咧咧的走了后面的人一顿操作猛如虎把你的钱取走了咋搞

为了使这些步骤一气呵成引入的办法就是 加锁

加锁

即 在你进去的时候门就被锁了其他的人就进不去了

然后你就可以完成 刷卡、输入密码 等等的操作等这些操作都完成了之后再把锁给打开然后你就可以出去了

下一个人也就可以进来重复和你一样的操作了

实际上银行里面的 ATM机 就是这样设计的

此时的 "你" 指的就是 "线程""ATM机" 指的就是 "对象""门上的锁" 指的就是 "锁""其他人" 指的就是 "其他的线程"

在 Java中加锁的方式有很多种其中最常见的加锁方式就是用 synchronized关键字 进行加锁

2.3.2 使用 synchronized关键字 进行加锁 

synchronized 从字面意思上翻译叫做 "同步"其实 实际上它所起的是 互斥的效果;

在一开始的时候列举了一个典型的线程不安全的例子

创建两个线程让这两个线程 同时并发 对一个变量自增 5w 次最终预期能够一共自增 10w 次;

package thread;
 
class Counter {
    //用来保存计数的变量
    public int count;
 
     public void increase() {
        count++;
    }
}
 
public class Demo14 {
    public static void main(String[] args) {
        //这个实例用来进行累加
        Counter counter = new Counter();
 
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
 
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count" + counter.count);
    }
}

那么怎么使用 synchronized关键字 来解决这个线程不安全的问题呢

—— 很简单我们在上面的 increase() 方法 前面加上 synchronized关键字即可写在 void 之前都可以

或者

 此时我们再执行程序发现无论再运行多少次发现运行结果是正确的


那么为什么加锁之后就可以来实现 线程安全的保障呢 

LOCK 这个指令是互斥的当 线程t1 进行 LOCK 之后t2 也尝试 LOCK那么 t2 的 LOCK 就不会直接成功

所以说在加锁的情况下线程的三个指令就被岔开了就可以保证 一个线程 save 之后另一个线程才 load于是此时的计算结果就准了 

2.3.3 synchronized 使用示例 

一synchronized 直接修饰普通方法

public class Demo14 {
    public synchronized void methond() {
 
   }
}

二synchronized 修饰静态方法

public class Demo14 {
    public synchronized static void method() {
 
   }
}

三修饰代码块

public class Demo14 {
    public void method() {
        synchronized (this) {
            
       }
   }
}

() 里面的 this 指的是是针对哪个对象进行加锁

加锁操作是针对一个对象来进行的

我们要重点理解synchronized 锁的是什么两个线程竞争的是同一把锁才会产生阻塞操作即 两个线程尝试使用两把不同的锁不会产生阻塞操作

如举例说明

换句话说1号滑稽 进入1号坑位只是针对 1号坑位 进行了加锁别人想要进入 1号坑位就需要阻塞等待但是 如果想要进入其他的 空闲坑位那么则不需要等待

这里的 滑稽老铁 指的就是 线程坑位的门上的锁其实就是 synchronized() 括号里面的东西  指的就是 要加锁的对象

注意

  1. 在Java里任何一个对象都可以用来做 锁对象即 都可以放在  synchronized() 的括号中其它的主流语言 都是专门搞了一类特殊的对象用来作为 锁对象大部分的正常对象 不能用来加锁
  2. 每个对象内存空间中都会有一个特殊的区域 —— 对象头JVM自带的对象的一些特殊的信息
  3. synchronized 写到普通方法上 相当于是对 this(可创建出多个实例) 进行加锁
  4. synchronized 写到静态方法上 相当于是对 类对象(整个 JVM 里只有一个) 进行加锁synchronized (类名.class)

三、Java标准库里面的线程安全类

在Java标准库里面很多线程都是不安全的如例如ArrayListLinkedListHashMapTreeMapHashSetTreeSetStringBuilder

当然还是有一些是线程安全的如Vector (不推荐使用)HashTable (不推荐使用)ConcurrentHashMap (推荐)StringBufferString

需要注意的是加锁也是有代价的它会牺牲很大的运行速度毕竟加锁涉及到了一些线程的阻塞等待以及 线程的调度所以可以视为一旦使用了锁我们的代码基本上就和 "高性能" 说再见了


总结

今天有关线程安全问题的上篇就讲到这里下一节内容我们将继续探讨线程安全的问题让我们下期再见

 

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: Java

“【JavaEE初阶】第五节.多线程 ( 基础篇 ) 线程安全问题(上篇)” 的相关文章