线程安全
概念:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境下应该的结果,则说这个程序是线程安全的
要考虑多个线程并发并行执行:多个线程之间的代码都是随机执行的 =》任何情况下如果存在不符合预期结果的,都是不安全的
public class Test {
static int n = 1;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(){
@Override
public void run() {
for(int i=0;i<10000;i++)
n++;
}
};
Thread t2 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 10000; i++)
n++;
}
};
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(n);
}
}
结果显示是随机的,为什么不安全?涉及到多个线程对n的修改
什么情况产生线程不安全?
多个线程执行共享变量的操作:
- 都是读:使用值进行判断,打印等(不存在线程安全)
- 至少一个线程写:线程不安全
线程不安全的原因?
-
原子性:多行指令是不可拆分的最小执行单位,就具有原子性
注意:Java中,某些特殊的代码,Java代码只有一行,但编译为class字节码,或jvm翻译字节码为机器码,可能会有多行;
典型:
n++,n--,++n,--n =》 1. 从内存读取到cpu的寄存器 2.修改 3.写回内存
new操作 等
-
可见性:一个线程对共享变量值的修改,能够及时被其他线程看到
Java内存模型(JMM):Java虚拟机规范中定义了Java内存模型。
目的:屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果
- 线程之间的共享变量存在 主内存 (Main Memory).
- 每一个线程都有自己的 "工作内存" (Working Memory) .
- 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
- 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存
因为每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的副本。所以当线程1的工作内存中的值修改时,线程2工作内存的值不一定回及时变化
为什么有这么多的内存?
这里的内存,为Java的抽象叫法;
- 主内存才是硬件角度的内存,
- 工作内存指cpu的寄存器和缓存器
为什么要来回拷贝?
因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍)
-
代码顺序性(有序性):执行字节码指令或执行机器码指令,都可能为了提高执行效率(不能重排序有前后依赖关系的指令),使用指令重排序的方式来执行
如何解决线程不安全问题?
设计多线程代码原则:满足线程安全(底线)的前提下,尽可能的提高执行效率
-
某个线程对共享变量的写操作,可以加锁来保证线程安全;
加锁:解决以上不安全三个原因:原子性,可见性,有序性;
Java中两种方式:
-
- synchronized 关键字:申请对给定的Java对象,对象头加锁
- Lock:这是一个锁的接口,它的实现类提供了锁这样的对象,可以调用方法来加锁/释放锁
-
若某个线程,对共享变量是读操作,使用volatile 关键字保证线程安全
volatile :修饰变量(变量的读操作,本身具有原子性)
只保证可见性,有序性,结合后满足线程安全
如果对共享变量的写操作,不依赖任何共享变量,也可以使用volatile保证线程安全
synchronized 关键字-监视器锁(monitor lock)
synchronized 的特性
原理:多个线程对同一个对象进行加锁操作,具有同步互斥的作用/效果;(基于对象头加锁操作,满足原子,可见,有序)
-
互斥(synchronized的底层是使用操作系统的mutex lock实现的.)原子性
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待.
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
Java对象头:
可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 "锁定" 状态(类似于厕 所的 "有人/无人").
如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态.
如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队
理解 "阻塞等待":
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝 试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的 线程, 再来获取到这个锁.
注意:
上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也 就是操作系统线程调度的一部分工作.
假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能 获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
-
刷新内存 可见性
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
所以 synchronized 也能保证内存可见性. 具体代码参见后面 volatile 部分.
-
有序性:某个线程执行一段同步代码,不论如何重排序,过程中不可能有其他线程执行指令。
-
可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
理解 "把自己锁死"
一个线程没有释放锁, 然后又尝试再次加锁.
//第一次加锁成功
lock();
//第二次加锁,锁已被占用,阻塞等待
lock();
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第 =二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无 法进行解锁操作. 这时候就会 死锁.
这样的锁称为 不可重入锁
原理:在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.
- 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取 到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
synchronized 使用
原理:多个线程对同一个对象进行加锁操作,具有同步互斥的作用/效果;(基于对象头加锁操作,满足原子,可见,有序)
synchronized 本质上要修改指定对象的 "对象头". 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.
语法一:同步代码块
语法二:同步实例方法
语法三:同步静态方法
package 线程安全;
public class SynchronizedUse {
//语法二:
public synchronized void t1(){
//代码;
}
//等同于:
public void t1_2(){
//this:当前线程(谁(当前类的一个实例对象)调用该实例方法,this就是谁)
synchronized (this){
//整个方法内部代码代码;
}
}
public static void main(String[] args) {
//任意对象,包括类对象
//语法一:同步代码块
Object 任意对象 = new Object();
synchronized (任意对象){
//代码;
}
synchronized (SynchronizedUse.class){
}
//语法二:同步实例方法
//语法三:静态同步方法
}
//语法三:
public static synchronized void t2(){
}
//等同于
public static void t2_2(){
synchronized (类对象.class){
}
}
}
注意:如果不同的线程,申请不同对象的锁,没有同步互斥的作用
Java标准库中的线程安全类
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
- ArrayList
- LinkedList(区别)
- HashMap(理解)
- TreeMap
- HashSet
- TreeSet
- StringBuilder(区别)
但是还有一些是线程安全的. 使用了一些锁机制来控制.
- Vector (不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
不加锁,但安全(不涉及修改)
- String
volatile 关键字
语法:修饰某个变量(实例变量,静态变量)
private static volatile boolean flag = false;
作用:
-
保证可见性:多个线程对同一变量的操作,具有可见性
代码在写入 volatile 修饰的变量的时候,
- 改变 线程工作内存 中 volatile变量副本 的值
- 将改变后的 副本的值 从 工作内存 刷新到 主内存
代码在读取 volatile 修饰的变量的时候,
- 从 主内存 中 读取 volatile变量 的最新值 到线程的工作内存中
- 从 工作内存 中 读取 volatile变量 的副本
volatile,强制读写内存,速度慢了,但数据变得准确
示例:
在这个代码中
- 创建两个线程 t1 和 t2
- t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.
- t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.
- 预期当用户输入非 0 的值的时候, t1 线程结束
public class VolatileUse {
static class Counter{
//没有 volatile 预期效果无法达到
public volatile int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
while(counter.flag==0){
//System.out.println("20");
}
System.out.println("结束");
});
Thread t2 = new Thread(()->{
Scanner sc = new Scanner(System.in);
System.out.println("input a number : ");
counter.flag = sc.nextInt();
});
t1.start();
t2.start();
}
}
-
禁止指令重排序,建立内存屏障
结合单例双重校验锁理解
注意:不保证原子性(例如++,--)
使用场景:共享变量的读操作,及常量赋值操作(这些操作本身具有原子性)
总结:保证线程安全的思路:
- 使用没有共享资源的模型
- 适用共享资源只读,不写的模型
- 不需要写共享资源的模型
- 使用不可变对象
- 直面线程安全
- 保证原子性
- 保证可见性
- 保证有序性
最难不过坚持!!!