线程安全与锁
1.什么是线程不安全问题
线程安全的本质是内存安全,在Java中我们绝大多数的数据都是存储在Java堆中,而Java堆是所有线程共享的。当多个线程访问同一个对象时,如果能够获得预期的结果,那么我们就会说这个对象是线程安全的,反之线程不安全
线程安全问题:多个线程同时执行了操作共享资源的临界区代码段发生了竟态条件,这个对象没有按照预期执行,我们就会说这个对象是线程不安全的或那段临界区代码是线程不安全的
2.案例理解线程安全问题:自增运算不是线程安全的
代码
public class Test {
public static int count = 0;
public static void main(String[] args){
for(int i=0;i<10000;i++){
new Thread(()->{
count++;
}).start();
}
Thread.sleep(10000);
System.our.println(count);
}
}
原因分析
为什么自增运算符不是线程安全的呢?实际上,一个自增运算符是一个复合操作,至少包括三个JVM指令:内存取值,寄存器增加1,存值到内存。这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行
比如在amount=100时,假设有三个线程读同一时间取amount值,读到的都是100,增加1后结果为101,三个线程都将结果存入到amount的内存,amount的结果是101,而不是103
而三个JVM 指令:内存取值,寄存器增加1,存值到内存,是不可以再分的,这三个操作具备原子性,是线程安全的,也叫原子操作。两个或者两个以上的原子操作合在一起进行操作,就不在具备原子性。比如先读后写,那么就有可能在读之后,这个变量被修改过,写入后就出现了数据不一致的情况,类似于以下情况:
3.线程安全的核心要素
共享资源
多个线程对一个资源进行写操作的时候,才有可能出现线程安全问题,线程独享的局部变量我们是无需考虑其线程安全问题的
临界区代码段
在并发情况下,临界区资源(共享资源)是受保护的对象。临界区代码段(Critical Section)是每个线程中访问临界资源的那段代码,这段代码往往存在读写操作或读读操作,因为读读是没有线程安全问题的
多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后进行临界区代码段,执行完成之后释放资源
竟态条件
竟态条件(Race Conditions)是可能在由于在访问Critical Section时没有互斥的访问而导致的特殊情况。如果多个线程在Critical Section的并发执行结果,可能因为代码的执行顺序不同而出现不同的结果,我们就说这时候在临界区出现了竟态条件问题
其他
原子性,可见性,一致性,这三个在JMM详细讲
4.如何解决线程安全问题
有很多种方式解决线程安全问题,比如:ThreadLocal,锁,原子变量,写时拷贝等等,咱们这里只介绍属于锁的一种叫做synchronized关键字,后面介绍其他方案
5.Synchronized关键字
介绍
Java中,线程同步使用最多的方法是使用synchronized关键字
它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住
这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
使用方式
同步方法
public synchronized void selfPlus(){
// 临界区代码段的代码块
}
同步代码段
private Object lock;
public void selfPlus(){
synchronized(lock){
// 临界区代码段的代码块
}
}
注意
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
- 静态方法锁的是类对象
- 尽量使用同步代码段方式,因为同步代码段方式粒度比同步方法更小,性能会更好,同步方法可能会造成不会出现线程安全的代码也被串行化访问
锁实例方法和静态方法的区别
在Java世界里一切皆对象。Java 有两种对象:Object实例对象和Class对象。每个类的运行时的类型信息,用Class对象表示的,它包含了与类名称、继承关系、字段、方法有关的信息
JVM将一个类加载入自己的方法区内存时,都会为其创建一个Class对象,对于一个类来说其Class对象也是唯一的
普通的synchronized实例方法,其同步锁是当前对象this的监视锁。那么,如果某个synchronized方法是static静态方法,而不是普通的对象实例方法,其同步锁是Class对象
公平性
不保证公平性。所以,线程获取锁的顺序是不可预测的,不能保证先请求锁的线程先获取锁
6.wait与notify
介绍
Java语言中“等待-通知”方式的线程间的通讯,使用对象的wait、notify 两类方法实现。每个Java对象都有wait、notify两类实例方法,并且wait、notify 方法和对象的监视器(Monitor)是紧密相关的
wait、notify两类方法在数量上不止两个。wait、notify两类方法不属于Thread类,而是属于Java对象实例(Object实例或者Class实例)
注意
- wait方法是可中断方法
- 必须配合synchronized中使用不然会报错
使用wait-notify完成生产者消费者模型
为了避免空轮询导致 CPU 时间片浪费,提高生产者消费者实现版本的性能,使用wait-notify完成生产者消费者模型
public class DateBuffer<T> {
// 数据缓冲区最大长度
public static final int MAX_AMOUNT = 10;
// 保存数据
private List<T> dataList = new LinkedList<>();
// 缓冲区数据长度
private Integer amount = 0;
private final Object LOCK_OBJECT = new Object();
private final Object NOT_FULL = new Object();
private final Object NOT_EMPTY = new Object();
//向数据区增加一个元素
public void add(T element) throws Exception {
while(amount > MAX_AMOUNT){
synchronized(NOT_FULL){
System.out.println("队列已经满了");
NOT_FULL.wait();
}
}
synchronized (LOCK_OBJECT){
dataList.add(element);
amount++;
}
synchronized (NOT_EMPTY){
NOT_EMPTY.notify();
}
}
//从数据区取出一个元素
public T fetch() throws Exception {
while(amount <= 0 ){
synchronized(NOT_EMPTY){
System.out.println("队列已经空了");
NOT_EMPTY.wait();
}
}
T element = null;
synchronized(LOCK_OBJECT){
element = dataList.remove(0);
amount--;
}
synchronized (NOT_FULL){
NOT_FULL.notify();
}
return element;
}
}
虚假唤醒
介绍
当一定的条件触发时会唤醒很多在阻塞态的线程,但只有部分的线程唤醒是有用的,其余线程的唤醒是多余的
比如说卖货,如果本来没有货物,突然进了一件货物,这时所有的顾客都被通知了,但是只能一个人买,所以其他人都是无用的通知
解决方案
使用wait方法时使用while进行条件判断,如果是在某种条件下进行等待,对条件的判断不能使用 if 语句做一次性判断,而是使用while循环做反复判断。只有这样,才能在线程被唤醒后继续都检查wait的条件,并在条件没有满足的情况下,继续等待