目录
1、线程不安全的因素
①抢占式执行
②每个线程操作自己的变量
③非原子性操作
④内存可见性问题
⑤指令重排序问题
2、volatile 解决内存可见性和指令重排序问题
2.1优点
volatile 可以解决可以解决内存可见性问题和指令重排序问题。
代码在写入volatile修饰的变量的时候:改变线程工作内存中volatile变量副本的值,将改变后的副本的值从工作内存刷入主内存
代码在读取volatile修饰的变量的时候:强制从主内存读取值,然后再拷贝到线程的工作内存中,从工作内存中读取
public class ThreadVolatile2 {
private static volatile boolean flag = true; //若此处不加volatile 线程1就不会结束执行
public static void main(String[] args) {
//创建子线程1
Thread t1 = new Thread(()->{
System.out.println("线程1开始执行"+ LocalDateTime.now());
while (flag){}
System.out.println("线程1结束执行"+LocalDateTime.now());
});
t1.start();
Thread t2 = new Thread(()->{
//休眠1s
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2修改 flag=false"+LocalDateTime.now());
flag=false;
});
t2.start();
}
}
2.2 缺点
volatile 虽然解决了内存可见性和指令重排序的问题,但是还是无法解决原子性问题。
3、锁(synchronized和lock)
3.1 synchronized(内存锁)
3.1.1 基本使用
①修饰静态方法
②修饰普通方法
③修饰代码块
用类对象来加锁
用类来加锁
用自定义锁对象来加锁
※注意:对于同一个业务的不同线程来说,加锁必须用同一个对象(用同一把锁),这里为什么可以用对象来进行加锁,每个对象我们可以看成一个类,这个类中有很多的属性,我们最关注的时对象头部分,因为里面有两个属性:①是否加锁的标识②进行加锁的线程的ID,一旦一个线程获得了锁,那么对象头就会标识,且录入进行加锁的线程的ID,这样另一个进程就只能等待了,如果用不同的对象进行加锁,就没有锁的意义了。(只是用了一下对象头的这个性质)
synchronized修饰代码块,代码块如果再静态方法中,则不能用this对象。
3.1.2 synchronized 特性
①互斥
synchronized 会起到互斥的效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到这个对象的synchronized 就会阻塞等待
进入synchronized 修饰的代码块相当于加锁
退出synchronized 修饰的代码块相当于解锁
②刷新内存
锁的工作过程
获得互斥锁->
从主内存拷贝变量的最新副本到工作内存->
执行代码->
将更改后的值刷回主内存->
释放锁
③可重入
synchronized 对一条线程来说是可重入的,不会出现自己把自己锁死的情况。
/**
* synchronized 可重入性测试
*/
public class ThreadSynchronized4 {
public static void main(String[] args) {
synchronized (ThreadSynchronized4.class) {
System.out.println("当前主线程已经得到了锁");
synchronized (ThreadSynchronized4.class) {
System.out.println("当前主线程再次得到了锁");
}
}
}
}
第二次进入对象的synchronized 方法会 查询对象头的两个属性:①当前线程是否已经得到锁②对象头中的ID是否和此线程的ID匹配。
3.1.3 实现原理
synchronized是如何实现的?※
JVM层面 synchronized 是依靠监视器 Monitor 实现的;从操作系统的层面来看,监视器又是依赖操作系统的互斥锁(Mutex)实现的。
监视器是一个概念或者说是一个机制,它来保障任何时候,只有一个线程能执行指定区域的代码,它就像一个建筑,建筑里有一个特殊的房间,这个房间同一时刻只能被一个线程所占有,进入该建筑叫做进入监视器,进入该房间称为获得监视器,退出该建筑叫退出监视器。
下面我们添加一个代码,通过查看字节码,探索一下底层是如何实现的
public class SynchronizedToMonitorExample {
public static void main(String[] args) {
int count = 0;
synchronized (SynchronizedToMonitorExample.class) {
for (int i = 0; i < 10; i++) {
count++;
}
}
System.out.println(count);
}
}
分析字节码可以看出,程序再执行时,多了两个方法:monitorenter 和 monitorexit ,分别是进入监视器和退出监视器,由此可知synchronized 是 依赖JVM的监视器Monitor 实现的。而在HotSpot中虚拟机中,Monitor底层是由C++ 实现的,它的实现对象是ObjectMonitor, ObjectMonitor 结构体的实现如下:
监视器的结构:
监视器的执行流程:
①线程尝试获得锁,如果获得成功就将owner字段设置为当前线程ID,说明当前线程已经获得锁,如果获取失败则先进行自旋CAS尝试获得锁(挽留),如果还是失败就进入EntrySet(监控集合)(阻塞)。
②当某个线程拥有了锁,也就是进去了建筑中的那个唯一的房间,执行操作过程中执行了wait方法,线程会释放锁,并进入WaitSet(待授权集合)等待被唤醒。
③当调用notify/notifyAll()方法进行唤醒时,待授权集合中的线程就会尝试再次获得锁。
④线程执行完之后,会唤醒监控集合中的线程,它们尝试获得锁。
※ synchronized 发展的流程:jdk1.6 之前 synchronized 默认是使用重量级锁的,性能较差,所以大家几乎不用,而jdk1.6 对synchronized 进行了优化:
无锁->偏向锁->轻量级锁->重量级锁 可以按需索取,是自动升级的。类似HashMap的底层实现: 数组->链表->avl红黑树
无锁:只有一个线程的话就无锁。
偏向锁:多个线程,锁中存了某个线程的ID,偏向于这个线程获得锁,这种是偏向锁。
轻量级锁:较多线程去争抢锁。
重量级锁:更多线程争抢锁。
3.2 Lock
3.2.1 使用
①创建Lock
②加锁lock.lock()
③释放锁 lock.unlock()
Lock 可以指定所得类型(公平锁or非公平锁)
3.2.2 非公平锁与公平锁
3.2.3 使用注意事项
① unlock操作一定要放在finally 中,如果不放在里面,假如前面代码抛出异常,后面代码不执行,就会出现锁永久占用的问题。
②lock()一定要放在try 之前,最起码放到try代码块的第一行,否则可能出现:未加锁而释放锁的操作或者释放锁的错误信息覆盖业务报错信息的情况
4、两种锁的比较
①Lock 更灵活。有更多的方法,比如 tryLock()..
②锁类型不同:Lock 默认是非公平锁,但是可以通过构造函数指定为公平锁;synchronized 只能为非公平锁。
③调用 lock 方法和 synchronized 线程的等待锁状态不同,lock方法会变成WAITING;synchronized会变成 BLOCKED。
④synchronized 是JVM层面提供的锁,它是自动进行加锁解锁操作的,对于开发者无感;而lock需要手动进行加锁解锁操作。
⑤synchronized 可以修饰方法和代码块;而lock只能修饰代码块。