多线程--线程安全

Posted Kirl z

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程--线程安全相关的知识,希望对你有一定的参考价值。

1. java 进程如何运行

java 类名
运行一个java进程 (系统分配java进程的内存空间),
加载class字节码到java进程的内存, java程序 (java虚拟机) 执行字节码指令 (一行一行的翻译为机器码, CPU执行机器码)

会启动main线程执行main主函数
内存 (栈, 堆, 方法区)

  • 栈 (线程私有) : 包含局部变量表 (局部变量 + 基础数据类型的值)
  • 堆 (线程共享) : 对象和数组 (虽然是共享, 但是线程能不能用, 取决于线程是否能获取到对象的引用)
  • 方法区 (线程共享) : 字符串常量池 + 静态变量 jdk1.6及之前在方法区, 1.7在堆

2. 线程安全

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

多线程是同时并发, 并行执行多行代码指令, 要考虑所有的情况都符合期望, 才是安全的

3. 线程不安全的原因

3.1 代码层面

多个线程对同一个变量的操作 (读, 写), 有一个写操作, 就有线程安全问题

3.2 原理层面

3.2.1 原子性

原子性: 多行代码执行, 执行时, 是一组不可再分的最小执行单位

多个线程同时并发并行的执行代码指令, 可能在一个线程操作一个共享变量时, 是有前后依赖关系, 指令之间有其他线程的操作, 就会导致线程不安全

表达出一组操作需要具有不可拆分(其他线程并发并行在中间插入指令执行)

(1) n++, n- -, ++n, - -n
分解为三步:

  1. 从主存读取变量值(int tmp = 从主存读取的值)
  2. 修改 (tmp = tmp + 1)
  3. 写回主存

CPU 执行线程中的指令, 考虑效率问题 (内存比 CPU 高速缓存慢很多), 把内存变量的值复制到 CPU高速缓存

(2) Object o = new Object();
分解为三步:

  1. 分配对象的内存空间(java进程)
  2. 对象初始化(成员变量, 实例化代码, 构造方法, 这些会编译为初始化指令)
  3. 赋值给变量

(3) 了解
long 等等 64位变量, 不具有原子性

3.2.2 可见性

在这里插入图片描述

主存: 线程都使用的共享区域, 对其中变量/对象的操作
工作内存; 线程之间互相不可见, CPU执行线程中的代码指令, 是从主存复制到CPU高速缓存

3.2.3 有序性

代码重排序: java 代码书序是固定的, 但是 jvm 执行字节码或者CPU执行机器码, 都是可能重排序指令顺序, 目的是提高运行效率

一段代码是这样的:

  1. 去前台取下 U 盘
  2. 去教室写 10 分钟作业
  3. 去前台取下快递
    如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问
    题,可以少跑一次前台。

重排序, 是会考虑指令前后的依赖关系的

4. 如何解决线程安全问题?

  • 一组代码, 如果存在多线程对共享变量的操作, 都需要考虑线程安全问题
  • 一般把多线程操作的共享变量, 成为临界资源
  • 一组代码, 称为临界区

锁, 在java层面, 也是基于对象来设计为锁的, 要是用同一把锁 (同一个锁对象, 才能解决线程安全)

思路:
临界区加锁, 多个线程执行临界区代码, 最终表现, 就是一个线程申请加锁, 执行代码, 释放锁, 其他线程申请失败, 需要等待 (不一定是阻塞态, 也可以是运行态)

在这里插入图片描述

一个线程一个线程依次的执行临界区代码

5. 解决多线程安全问题

5.1 synchronized 关键字

同步的关键字: 依次执行某段代码

使用:
(1) 静态方法上: 加锁整个方法, 锁对象为当前的类对象 (Class <当前类型>, 类对象)

private synchronized static void t1() {
    // SynchronizedThread.class 类对象
}

(2) 实例方法上: 加锁整个方法, 多对象为 this

synchronized (this) {
    // 那个对象调用该实例方法, this就是指谁
}

(3) 同步代码块:

synchronized (锁对象) {
    
}

作用: 对同一对象加锁的线程, 造成同步互斥作用 (多个线程依次执行临界区代码)
注意: 如果不是使用同一个对象加锁, 意味着能同时执行 (并发并行)

synchronized 加锁的原理(部分):

  1. 本质上是对对象头进行加锁, 对同一个对象加锁的线程, 是同步互斥 (一个对象有一个线程加锁后, 必须释放锁, 其他线程才能获取到)
  2. 底层原理是基于操作系统的锁实现

5.2 volatile 关键字

volatile 是修饰一个变量的关键字

作用:

  1. 保证变量的可见性 (分解为字节码指令后的, 有变量的指令行, 变量有可见性)
  2. 建立一个内存屏障, 禁止指令重排序

使用场景:
(1) 多线程对共享变量的操作, 如果代码行本身保证了原子性, 就可以不加锁, 只使用volatile保证可见性, 该共享变量的操作也是线程安全的

  • 读是原子性 ;
  • 写 (修改, 赋值) 操作: 值不依赖共享变量 (n++, n = n + 1), 比如是一个常量, 就是原子性
private static boolean isStop = false;
while (!isStop) {
//
}

以上保证线程安全的代码, 不需要加锁, 多线程可以并发并行的执行, 效率是非常高的

(2) 多线程代码设计目标: 线程安全的情况下, 尽可能的提高效率

具体的参考实现方式, 参考:
临界区代码越多, 多线程对临界区加同一个锁, 同步互斥时, 执行临界区代码效率越低

优化方案:

  1. 锁的细粒度化 (临界区代码行越少) (共享变量的写操作的代码行)
  2. 哪些代码不加锁, 也能保证线程安全 (读的共享变量使用 volatile, 写一般都会依赖其他变量, 暂时不考虑)

以上总体可以称为读写分离 (读读并发, 读写并发, 写写互斥)

以上是关于多线程--线程安全的主要内容,如果未能解决你的问题,请参考以下文章

多个请求是多线程吗

多个用户访问同一段代码

线程学习知识点总结

为啥基于锁的程序不能组成正确的线程安全片段?

markdown 线程安全相关片段

多线程带来的风险——线程安全