Java并发04之线程同步机制

1 线程安全

1.1 线程安全的变量

  • 成员变量和静态变量:如果它们没有共享,则线程安全。如果它们被共享了,但只有读操作,则线程安全。如果它们被共享了,且有读写操作,则需要考虑线程安全。
  • 局部变量:局部变量是线程安全的,但局部变量引用的对象则不一定是安全的。如果局部变量引用的对象没有逃离方法的作用范围,那么就是线程安全的。如果该对象逃离方法的作用范围,需要考虑线程安全。

1.2 Spring Bean

Spring容器本身并没有提供Bean的线程安全策略,因此Spring Bean对象(singleton作用域)本身不具备线程安全的特性。Controller、Service对象一般都是单例的,在并发环境下会涉及到线程安全问题。因此,它们通常是被设计为无状态对象,从而保证该对象是线程安全的。

无状态对象,就是没有成员变量的对象,或者成员变量也是一个无状态对象。因此,无状态对象不能保存可变数据,是线程安全的。在实际开发中,Controller、Service对象通常不会保存共享数据,而是提供对共享数据进行操作的方法。

1.3 如果保证线程安全

  • 使用锁机制保证读写操作的同步性,如synchronized、ReentrantLock。
  • 使用juc包的并发集合或原子类,如ConcurrentHashMap、AtomicInteger,这些工具类本质上也是使用锁机制、AQS队列或CAS做到读写操作的同步性。
  • 使用ThreadLocal提供变量的线程本地副本,实现变量的线程隔离。

2 synchronized关键字

2.1 Java对象头

2.1.1 对象组成部分

一个对象包括对象头,实例数据和填充数据。其中,对象头又包括三部分:

  • Mark Word:存储了对象的哈希码、分代年龄和锁信息。本小节主要是介绍Mark Word。
  • 类元数据地址:存储到对象类型数据的指针。
  • 如果当前对象是数组的话,还会存储数据的长度。

在这里插入图片描述

2.1.2 锁类型

对象头中的Mark Word存储了锁类型的相关信息,根据锁对象状态的不同,可以分为5类:

  • 无锁状态:偏向锁标志0,锁标志位01
  • 偏向锁状态:偏向锁标志1,锁标志位01
  • 轻量级锁状态:偏向锁标志无,锁标志位00
  • 重量级锁状态:偏向锁标志无,锁标志位10
  • GC标记:偏向锁标志无,锁标志位11

2.1.3 锁对象

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是Synchonized关键字括号里配置的对象

2.2 synchronized底层实现

JDK 6之前,synchronized解决的是多个线程之间访问资源的同步性,被它修饰的方法或者代码块在任意时刻只能有一个线程执行。synchronized同步代码块的实现使用的是monitorenter和 monitorexit指令,分别插入到代码块的开始和结束位置。任何锁对象都有一个monitor对象与之关联,当线程执行到monitorenter指令时,将会尝试获取锁对象所对应的monitor的所有权,获得monitor的所有权也就意味着获得了同步锁。如果获取成功,就会进入monitor的所有者Owner里面,如果锁被占用了,则会进入EntryList列表,变成阻塞状态。

JDK 6之后,为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。此时锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁,这几个状态会随着竞争情况逐渐升级,并且锁可以升级但不能降级。
在这里插入图片描述

2.2.1 无锁状态

当没有被当做锁的时候,就是个普通对象。此时对象头里锁标志位为01,偏向锁标志为0。

2.2.2 偏向锁状态

为了让获得锁的代价更低引入了偏向锁。当一个线程获取锁时,锁自动升级为偏向锁,会在锁的Mark Word里存储线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试是否仍然为同一线程。如果测试成功,表示线程已经获得了同步锁。

2.2.3 轻量级锁状态

如果锁的Mark Word中存储的线程ID不是当前想要竞争锁的线程ID,此时当前线程会尝试使用CAS去替换锁的Mark Word中存储的线程ID,如果替换成功,表示之前的线程已经执行结束了,锁仍然为偏向锁。如果竞争失败,表示当前锁发生了竞争,锁会升级为轻量级锁。
在这里插入图片描述

  • JVM首先会在当前线程的栈帧中开辟锁记录空间Lock Record,并将对象头中的Mark Word复制到Lock Record中,这个过程官方称为Displaced Mark Word
  • 然后尝试使用CAS将Mark Word替换为指向Lock Record的指针,如果竞争成功,代表线程抢到了锁。如果失败,表示锁已经被占有,就会不断自旋。

总结:在某个线程获得轻量级锁的过程中,会将Mark Word中的信息保存到该线程的栈中一块称之为Lock Record的地方,然后再将锁的对象头中的Mark Word替换一个指针,该指针指向Lock Record。如果说偏向锁只是一种线程和锁对象弱关联的关系,那么,轻量级锁就会升级为强关联的关系,即线程和锁对象“你中有我,我中有你”。

2.2.4 重量级锁

当轻量级锁竞争激烈时,会产生过多的自旋现象。因此引入了排队机制,即使用Monitor对象以及WaitSet和EntryList。但锁升级为重量级锁后,未竞争到锁的线程,不会再进行自旋,而是进入了EntryList队列中进行排队。
在这里插入图片描述

重量级锁的本质:如果给Java对象使用了synchronized并加上了重量级锁,那么这个对象就会关联一个Monitor对象,在Mark Work中会有一个指针指向这个Monitor对象。

  • Owner:当前锁的持有者,Owner只能指向一个线程。
  • WaitSet:等待队列,是之前获得过锁,但是条件不满足后,又退出锁的拥有,等待条件。
  • EntryList:被阻塞的队列,在Owner指向的对象不为空的时候,有其他线程想要获得锁,那么会进入EntryList队列。
  • WaitSet和EntryList的区别:EntryList执行条件都满足了,只需要获得锁。WaitSet是条件不满足,如果条件满足的话,重新进入到EntryList进行锁的竞争。

2.2.5 锁类型总结

当一个线程想要获得锁对象时,就会发现锁对象的Mark Work并不是存储null、哈希码和分代年龄(无锁),也不是存储线程指针、Epoch和分代年龄(偏向锁),也不是存储指向某一线程的Lock Record,而是存储了一个指向一个Monitor对象的指针,那么这个线程就会知道当前锁是一个重量级锁。此时,线程就会跑到Monitor对象的EntryList中,等待获得锁。

  • -XX:+UseBiasedLocking 开启偏向锁
  • -XX:BiasedLockingStartupDelay=0 关闭偏向锁延迟
  • -XX:+UseSpinning 开启轻量级锁
  • -XX:PreBlockSping 轻量级锁自旋次数

2.2.6 锁撤销

  • 偏向锁撤销:偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态。
  • 轻量级锁撤销:使用CAS尝试将线程栈中的锁记录复制的Displaced Mark Word替换锁的Mark Word,如果替换成功,则轻量级锁释放成功。

2.3 synchronized失效场景

2.3.1 失效场景1

synchronized修饰非static方法时,锁对象是当前对象。如果这个类是非单例的,会导致锁失效!例如:

  • 在Spring程序中,bean设置为prototype
  • 未被Spring管理的类,调用其普通同步方法同样会失效

解决方案:

  • 使用单例模式
  • 未被Spring管理的类,如某些工具类,应该尽可能使用静态同步方法
  • 使用分布式锁

2.3.2 失效场景2

Spring的@Transcational事务管理使用AOP方式实现,即在方法的执行开始和结束插入事务开启和提交或者回滚命令。

如果一个同步方法上有事务管理,如果一个线程在释放锁但还未提交事务时,另一个线程获取到锁并执行方法,可能会导致读取脏数据。

2.3.3 失效场景3

synchronized的锁时当前对象,因此也称为进程锁,即只能保证一个进程内的读写操作同步。当前我行应用通常是多实例部署,如果操作的共享数据是下游数据,如数据库数据等等,那么进程锁synchronized是不起作用的。

解决方案:使用分布式锁

3 volatile关键字

3.1 Java内存模型

3.1.1 Java内存模型

JMM(Java Memory Model):Java内存模型,是JVM规范中所定义的一种内存模型,它屏蔽掉了底层不同计算机的区别。JMM规定:所有的共享变量都存储于主内存,每一个线程有自己的本地内存,用于存储共享变量的副本。线程对变量的所有的操作都必须在本地内存中完成,而不能直接读写主内存中的变量,操作完成后再将共享变量刷写回主内存。
在这里插入图片描述

3.1.2 JMM三大特性

  • 可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。Java中普通的共享变量不保证可见性,因为每个线程本地内存中的副本数据修改后被写入内存的时机是不确定的,多线程并发下很可能出现"脏读"。(解决方案:synchronized、volatile)
  • 原子性:指一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰。(解决方案:synchronized)
  • 有序性:为了提高性能,编译器和处理器通常会对指令序列进行重新排序。指令重排可以保证串行语义一致,但不保证多线程间的语义也一致,即可能产生"脏读"。(解决方案:volatile)

3.1.3 happens-before原则

happens-before(先行发生原则)的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

  • 次序原则:一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作;
  • 锁定规则:锁的获取的先后顺序;
  • volatile变量规则:对一个volatile变量的读写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的。
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;可以通过Thread.interrupted()检测到是否发生中断。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过isAlive()等手段检测线程是否已经终止执行。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

3.1.4 as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

3.2 volatile关键字

3.2.1 volatile作用

volatile字面意思是易变的、不稳定的。其作用是告诉虚拟机该变量是极有可能多变的,此处会禁止一些优化措施,不能随意变动目标指令,即禁止指令重排。此外,每一次读取volatile变量应该去共享内存中读取。因此,volatile能够保证有序性和可见性。

3.2.2 为什么说volatile无法保证原子性

简单的说,修改volatile变量分为四步:

  1. 读取volatile变量到本地缓存
  2. 修改变量值
  3. 本地缓存值刷写回共享内存
  4. 插入内存屏障,即lock指令,让其他线程可见。插入lock指令,会进行锁缓存,也就是会广播让其他线程的缓存失效,所有线程如果要重新读取变量需要重新到共享内存去读取。

因此,volatile通过插入内存屏障来保证可见性,通过禁止指令重排保证有序性,但是不能保证原子性!在高并发场景下,因为插入内存屏障前的操作并不是原子性的,以i++为例,如果线程1执行完i++之后还没来得及插入内存屏障刷写回主内存就被挂起,此时线程2读取完i进行i++,此时再挂起轮到线程1刷写回主内存,就会发生线程安全的问题了。

3.3 对比synchronized和volatile

synchronized关键字和volatile关键字是两个互补的存在:

  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量,而synchronized关键字修饰的是方法以及代码块。
  • volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性。

3.4 再看双重校验锁

public class Singleton {

	private static volatile Singleton singleton = null;

	private Singleton() {    
	}

	public static Singleton getInstance() {
		// 如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。
        if (singleton == null) {		
            synchronized (Singleton.class) {
				  // 抢不到锁的线程会进入锁对象所对应的monitor的EntryList列表中,当第一个线程执行完释放锁之后,其他阻塞线程获取到锁之后就会创建新的对象,所以得进行二次判空
                if (singleton == null) {		
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
 }

采用volatile关键字修饰也是很有必要的,singleton = new Singleton(); 这段代码其实是分为三步:

  • 为singleton分配内存空间
  • 初始化singleton
  • 将singleton指向分配的内存地址

由于JVM具有指令重排的特性,执行顺序有可能变成1 -> 3 -> 2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程T1执行了1和3,此时T2调用getInstance()后发现singleton不为空,因此返回singleton,但此时singleton还未被初始化。使用volatile可以禁止JVM的指令重排,保证在多线程环境下也能正常运行。

4 ReentrantLock

4.1 可重入锁

可重入锁:是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。

public static void main(String[] args) {
    new Thread(() -> {
        lock.lock();
        System.out.println("第1次获取lock:" + Thread.currentThread().getName());
        try {
            lock.lock();
            System.out.println("第2次获取lock:" + Thread.currentThread().getName());
            try {
                System.out.println(Thread.currentThread().getName());
            } finally {
                lock.unlock();
            }
        } finally {
            // 加锁几次就要解锁几次
            lock.unlock();
        }
    }).start();

    new Thread(() -> {
        lock.lock();
        try {
            System.out.println("第1次获取lock:" + Thread.currentThread().getName());
        } finally {
            lock.unlock();
        }
    }).start();
}

4.2 对比synchronized和ReentrantLock

synchronized是依赖于JVM实现的,并没有直接将API暴露给我们。ReentrantLock是API层面实现的,需要lock()和unlock()方法来完成。相比synchronized,ReentrantLock增加了一些高级功能:

  • 等待可中断: ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。
  • 可实现公平锁: ReentrantLock可以是公平锁,也可以是非公平锁,默认是非公平的。synchronized只能是非公平锁,ReentrantLock默认情况也是非公平的。

5 锁模型总结

5.1 公平锁和非公平锁

  • 公平锁:多个线程按序来获取锁;
  • 非公平锁:后申请锁的线程可能比先申请锁的线程更先获取到锁,可能会造成线程饥饿现象;
  • 具体实现:ReentrantLock可通过构造函数指定是否是公平锁,默认是非公平锁。而synchronized只能是非公平锁。

5.2 独享锁和共享锁

  • 独享锁(排他锁):每次只能有一个线程能持有锁;
  • 共享锁:允许多个线程同时获取锁,并发访问共享资源;
  • 具体实现:ReentrantLock是独享锁。ReadWriteLock中,读锁是共享锁,写锁是独享锁。synchronized方法是独享锁。

5.3 乐观锁和悲观锁

  • 乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁。只是在更新的时候会判断一下在此期间别人有没有去更新这个数据。乐观锁适用于多读的应用类型,这样可以提高吞吐量。乐观锁一般会使用版本号机制或者CAS算法实现。

    • 版本号机制:通过给共享资源加上一个版本号,表示数据被修改的次数,当数据被修改时,版本号会加1。当线程A要更新数据值时,在读取数据的同时也会读取版本号值,在提交更新时,若刚才读取到的版本号值与当前共享资源的版本号值相等时才更新,否则重试更新操作,直到更新成功。
    • CAS算法:需要读写的内存值V,进行比较的值A,拟写入的新值B。当且仅当内存V上的值等于A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作。
  • 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

  • 具体实现:乐观锁适用于多读场景,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。对应的,悲观锁适用于多写场景。JUC的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。synchronized和 ReentrantLock等独占锁就是悲观锁思想的实现。

  • 14
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值