什么是线程安全问题?
简单来说就是当多个线程同时访问某个方法时,这个方法无法按照我们预期的行为来执行,那么我们认为这个方法是线程不安全的
导致线程不安全的主要原因
1.原子性
什么是原子性
比如在数据库事务的ACID特性中 当前操作中包含多个数据库事务操作要么同时成功要么同时失败 不允许存在部分成功部分失败的情况
而在多线程中的原子性与数据库的原子性相同 它是指一个或者多个指令操作在CPU执行过程中不允许被中断
案例
public class ThreadDemo {
private int i = 0;
public void incr() {
i++;
}
public static void main(String[] args) throws Exception {
ThreadDemo threadDemo = new ThreadDemo();
Thread[] threads = new Thread[2];
for (int k = 0; k <2 ; k++) {
threads[k] = new Thread(()->{
for (int j = 0; j<10000 ; j++) {
threadDemo.incr();
}
});
threads[k].start();
}
threads[0].join();
threads[1].join();
System.out.println(threadDemo.i);
}
}
以上案例同时启动两个线程 每个线程对i进行++正常情况下 i应该=20000 但是打印出来的数值却小于两万 和预期结果不一致导致这一现象发生的原因就是原子性问题
上述案例执行过程分析
1.线程1先获得CPU执行权 在CPU将i=0加载到寄存器 中后出现线程切换 CPU将执行权切换给线程2并保留当前的CPU上下文
2.线程2同样去内存中将i加载到寄存器中进行计算 然后把计算结果写回内存
3.线程2释放了CPU资源 线程1重新获得执行权后恢复CPU上下文而这时i=0
4.最后计算i的结果比预期小‘
产生原子性问题的原因
1.CPU时间片切换
2.执行指令的原子性(线程运行的程序或者指令是否具有原子性)
原子性问题的解决方法
1.保证i++操作在执行过程中不存在上下文切换
2.互斥条件保证串行执行
在Java中 提供了一种解决方案 互斥锁 synchronized
以下代码只要在incr方法上加上synchronized 关键字就可使得运行结果一定=20000
public class ThreadDemo {
private int i = 0;
public synchronized void incr() {
i++;
}
public static void main(String[] args) throws Exception {
ThreadDemo threadDemo = new ThreadDemo();
Thread[] threads = new Thread[2];
for (int k = 0; k <2 ; k++) {
threads[k] = new Thread(()->{
for (int j = 0; j<10000 ; j++) {
threadDemo.incr();
}
});
threads[k].start();
}
threads[0].join();
threads[1].join();
System.out.println(threadDemo.i);
}
}
2.有序性
volatile 的时候在详细说
3.可见性
volatile 的时候在详细说
synchronized的作用
以上示例中得出结论导致线程安全的根本原因在于 多个线程同时操作共享资源就需要保证共享资源的独占性 在Java中提供了synchronized关键字 他能保证同一时刻只有一个线程执行该代码块
synchronized是互斥锁 相当于把并行的线程变成了串行执行 但是也牺牲了性能
synchronized的作用范围
在synchronized中提供了类锁和对象锁
类锁
类锁是全局锁 当多个线程调用不同实例对象的同步方法时会产生互斥
public static synchronized void incr() {
......
}
synchronized (ThreadDemo.class) {
......
}
@Slf4j
public class ThreadDemo {
private int i = 0;
public void incr(){
synchronized (ThreadDemo.class) {
while (true) {
log.info("当前访问线程"+Thread.currentThread().getName());
try {
Thread.sleep(100);
}catch (Exception e) {
e.fillInStackTrace();
}
}
}
}
public static void main(String[] args) throws Exception {
ThreadDemo threadDemo = new ThreadDemo();
ThreadDemo threadDemo1 = new ThreadDemo();
new Thread(()->threadDemo.incr(),"threadDemo").start();
new Thread(()->threadDemo1.incr(),"threadDemo1").start();
}
}
根据类锁的范围 即使有多个对象实例 哪个线程抢到了锁 哪个线程就打印自己的线程名称
对象锁
当多个线程用同一个对象实例的同步方法时会产生互斥
public synchronized void incr(){
......
}
private Object lock = new Object ();
public void incr(){
synchronized (lock) {
......
}
}
@Slf4j
public class ThreadDemo {
private Object lock = new Object();
public void incr(){
synchronized (lock ) {
while (true) {
log.info("当前访问线程"+Thread.currentThread().getName());
try {
Thread.sleep(100);
}catch (Exception e) {
e.fillInStackTrace();
}
}
}
}
public static void main(String[] args) throws Exception {
ThreadDemo threadDemo = new ThreadDemo();
ThreadDemo threadDemo1 = new ThreadDemo();
new Thread(()->threadDemo.incr(),"threadDemo").start();
new Thread(()->threadDemo1.incr(),"threadDemo1").start();
}
}
16:34:02.861 [threadDemo] INFO com.alipay.alibabademo.thread.ThreadDemo - 当前访问线程threadDemo
16:34:02.861 [threadDemo1] INFO com.alipay.alibabademo.thread.ThreadDemo - 当前访问线程threadDemo1
16:34:02.964 [threadDemo1] INFO com.alipay.alibabademo.thread.ThreadDemo - 当前访问线程threadDemo1
16:34:02.964 [threadDemo] INFO com.alipay.alibabademo.thread.ThreadDemo - 当前访问线程threadDemo
16:34:03.078 [threadDemo] INFO com.alipay.alibabademo.thread.ThreadDemo - 当前访问线程threadDemo
我们可以看到 相同的代码 在使用对象锁的情况下 当两个不同实例的方法执行时 并没有达到互斥的目的 根源在于 synchronized (lock )锁的范围太小
Class是在JVM启动过程中加载的 每个.class文件 被装载之后会产生一个class对象 class在JVM进程中是唯一的 通过static修饰的成员变量及方法的声明周期都是类级别的 会随着类的定义被分配到内存中 随着类的卸载被回收掉
而对象的生命周期随着实例对象的创建而开始 随着实例对象的回收而回收
因此类锁和对象锁最大的区别是锁对象的声明周期不一样 如果要达到互斥那么就需要多个线程竞争同一个对象锁
synchronized原理实现
同步锁设计猜想
1.同步锁的特性就是排他互斥 要达到这个目的 多个线程就必须去争抢同一个资源
2.在同一时刻当一个线程获取到了这个资源那么其他资源就必须要去等待
3.处于等待的线程肯定不能去一致占用CPU
4.如果很多的线程被阻塞 那么是否可以设计一个容器去装这些阻塞的线程 当获得锁的线程运行结束之后在去容器里面唤醒线程 被唤醒的线程再去尝试抢占锁
同步标记如何存储的?
通过上述我们可以猜想 有一个互斥的标记来记录是否已经有线程获取到了锁
如果有的话那其他的线程就需要去阻塞等待 那么这个互斥标记是怎么存储的呢?
Mark Word的存储结构
一个Java对象被初始化之后会被储存在堆内存中 那么这个对象结构又是如何呢?
Java对象的存储结构可以分为三个部分对象头、实例数据、对象填充
对象头
对象头又由三个部分组成
Mark Word
Mark Word 记录了对象和锁相关的信息,当这个对象作为锁对象来实现synchronized的同步操作时锁标记和相关信息都存储在Mark Word中
不同锁状态下32位Mark Word的结构信息
不同锁状态下64位Mark Word的结构信息
不管是32还是64位系统中 Mark Word中都会包含GC年龄分代 锁状态标记 hashCode等信息。从图中可以看到一个锁状态字段 它包含的状态 分别是 无锁,偏向锁,轻量级锁,重量级锁 。
Klass Pointer
表示指向类的指针 JVM通过这个指针来确认对象具体属于哪个类的实例
Length
表示数组长度
实例数据
实例数据就是所有类的成员变量 比如一个对象中包含 int、long等类型成员变量这些成员变量就存储在实例数据中
对齐填充
其目的是使得当前对象实例占用的存储空间时8字节的倍数 如果一个对象实例不是8字节的倍数会使用对齐填充
为了减少CPU访问内存的频率 从而达到性能提升的效果所以设计的8的倍数
对象实际存储图解
synchronized的锁类型
1.偏向锁
2.轻量级锁
3.重量级锁
在JDK1.6之前synchronized只提供了重量级锁的机制 JDK1.6之后synchronized做了很多优化 针对锁类型增加了偏向锁和轻量级锁 这两种锁的核心设计理念就是如何让线程在不阻塞的情况下达到线程安全的目的
偏向锁的原理分析
偏向锁会在线程没有竞争的情况下去访问synchronized同步代码块时会先尝试通过偏向锁来抢占访问资源 这个抢占过程是基于CAS完成的 如果抢占成功 则直接修改对象头中的锁标记 其中偏向锁标记为1 锁标记为01 以及存储当前线程的ID ,而偏向的意思就是如果线程X获得了偏向锁 那么当线程X后续在访问这个同步方法时只需要判断对象头中的线程ID和线程X是否相等即可 如果相等不需要再去抢占锁 直接获取访问资格
偏向锁是没有线程竞争情况下实现的一把锁 但是不能排除存在锁竞争的情况 所以偏向锁获取分为两种情况
1.没有锁竞争的情况
1.在没有锁竞争的时候 线程1访问同步代码块时从当前线程的栈中找到一个空闲的Basic ObjectLock 锁对象
2.将BasicObjectLock中的oop指针指向当前的锁对象lock
3.获得当前锁对象的对象头 通过对象头来判断是否可偏向 也就是说是锁标记为01 并且Thread为空
4.如果为可偏向状态 那么判断当前偏向的线程是不是线程1 如果偏向的是自己 则不需要在抢占锁 直接可以运行代码块
5.如果为不可偏向状态 则需要通过轻量级锁来实现锁的抢占过程‘
6.如果对象锁lock偏向其他线程或者没有偏向任何一个线程 则先构建一个匿名偏向的MarkWord 然后通过CAS方法把一个匿名偏向的Mark Word修改为偏向线程1 如果当前锁对象lock 已经偏向其他线程 那么CAS一定会失败’
2.有锁竞争的情况
假设线程1获得了偏向锁 此时线程2去执行代码块代码 如果访问的是同一个对象锁 则会触发锁竞争 并触发偏向锁撤销进行如下流程
1.线程2调用撤销偏向锁方法 尝试撤销lock锁对象的偏向锁
2.撤销偏向锁需要到达一个安全点才能执行 当到达安全点后 会暂停或的偏向锁的线程1
3.检查获得偏向锁的线程1
如果线程1还在执行同步代码块中的指令 直接把锁对象lock升级成轻量级锁 并且指向线程1 表示线程1获得轻量级锁
如果线程1执行完了代码块中的指令 或者处于非活的状态下 直接把偏向锁撤销恢复无锁状态 然后线程2升级成轻量级锁 通过轻量级锁获取资源
偏向锁的释放
把lock Record释放锁对象的Mark Word释放为空
轻量级锁原理分析
在线程没有竞争时,使用偏向锁能够在不影响性能的前提下获得锁资源,但是同一时刻只允许一个线程获得锁资源 如果突然有多个线程来访问同步方法,那么没有抢占到锁资源的线程 进行一定次数的重试(自旋) 。比如线程第一次没抢到锁则重试几次,如果在重试的过程中抢占到了锁 那么线程就不需要阻塞这种实现方式称为自旋锁
如果一直自旋来重试抢占锁的方式是有代价的,cpu一直处于运行状态 如果持有锁的线程占有锁的时间比较短 那么自旋等待带来的性能提升比较明显,如果持有锁的线程占用资源时间较长 那么自旋的线程就会浪费CPU资源所以重试必须有一个限制
在JDK1.6中默认自旋次数是10次 并且可以通过-XX:PreBlockSpin参数来调整自旋次数 同时JDK1.6还对自旋进行了优化引入了自适应自旋,自适应自旋的次数不是固定的而是根据上次同一个锁上的自旋次数以及持有者的状态决定的 如果在同一个锁对象上通过自旋等待成功获得过锁 并且持有锁的线程正在运行中 那么JVM会认为此次自旋也有很大机会获得锁 因此会将这个线程的自旋时间相对延长 反之如果一个锁对象 通过自旋获得锁很少成功 那么JVM会缩短自旋次数
重量级锁的原理分析
轻量级锁能够通过一定次数的重试让没有获得锁的线程有可能抢占到锁资源 但是轻量级锁只能在获得锁的线程持有锁的时间较短的情况下才能起到提升同步锁性能的效果 如果持有锁线程占用资源的时间较长,那么不能让那些没有抢占到锁的自旋不断自旋 否则会占有过多的CPU资源
如果没抢占到锁的线程通过一定次数的自旋后 发现仍然没有抢占到锁就只能阻塞等待 所以最终会升级到重量级锁 通过系统层面的互斥量来抢占资源
锁升级的流程
当一个线程访问增加了synchronized关键字代码块时 如果偏向锁是开启状态 则先尝试通过偏向锁来获得锁资源 这个过程用CAS完成 如果当前已经有其他线程获得了偏向锁 那么抢占锁资源的线程由于无法获得锁 所以会尝试升级到轻量级锁来进行锁资源抢占轻量级锁就是通过多次CAS来完成的 如果这个线程通过多次自旋仍然无法获得锁资源 那么最终只能升级到重量级锁实现锁等待
偏向锁实现原理
偏向锁就是使用CAS机制来替换对象头中的ThreadId如果成功则获得偏向锁 否则就会升级到轻量级锁
轻量级锁的实现原理
如果偏向锁存在竞争或者偏向未开启那么当线程访问synchronized同步代码块时会采用轻量级锁来抢占锁资源 获得访问资格
轻量级锁流程
1.线程2进入同步代码块之后 JVM会给当前线程分配一个Lock Record(Basic Obejct Lock对象) 它的成员对象BasicLock中有一个成员属性markOop_displaced_header专门用来保存锁对象lock的原始Mark Word
2.构建一个无锁状态的Mark Word(lock锁对象的mark Word 但是锁状态是无锁) 把这个无锁状态的Mark Word设置到Lock Record 中的_displaced_header字段中
3.通过cas 将lock 锁对象的Mark Word 替换为指向Lock Record的指针 如果替换成功 表示轻量级锁抢占成功线程2可以执行代码块
4.如果cas失败 则说明当前lock锁对象不是无锁状态 会触发锁膨胀 升级成重量级锁
轻量级锁的释放
1.采用cas把Lock Record中——displaced_header存储的lock锁对象的Mark Word替换到lock锁对象的MarkWord中
2.如果替换成功则轻量级锁释放完成
3.如果替换失败 说明释放锁的时候发生了竞争 触发锁膨胀 在调用重量级锁的释放锁的方式完成锁的释放
重量级锁的实现原理
线程在执行代码块的时候 发现锁状态是轻量级锁 并且有线程抢占了锁资源 当自旋到一定次数之后该线程会触发锁膨胀会膨胀成重量级锁 因此重量级锁是在锁竞争的场景下
重量级锁的释放
1.把ObjectMonitor中持有锁的对象——owner设置为null。
2.从_cxq队列中唤醒一个处于阻塞队列中的线程。
3.被唤醒的线程重新竞争重量级锁 由于synchronized是非公平锁,所以不一定被释放的锁就能抢到锁资源。