概念
当使用多个线程来访问同一个数据时,将会导致数据不准确,相互之间产生冲突,非常容易出现线程安全问题,比如多个线程都在操作同一数据,都打算修改商品库存,这样就会导致数据不一致的问题。
所以我们通过线程同步机制来保证线程安全,加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。线程同步本质就是“排队“,多个线程之间要排队,然后一个一个对共享资源进行操作,而不是同时进行操作,从而保证线程安全(即保证原子性、可见性、有序性)。
锁
概述
在Java多线程环境下我们通过锁这种方式来保证共享资源的正确、线程安全,即在线程操作某个共享资源之前先对资源加锁,保证操作期间没有其他线程访问资源,当操作完成后再对共享资源释放锁供其他线程访问
- Java中锁是一种同步机制,用于控制多个线程对共享资源的访问。
- 锁可以防止多个线程同时对同一个共享资源进行写操作,从而避免数据的不一致性和错误。
- 锁是一种互斥工具,它能够确保同一时间只有一个线程可以访问共享资源
- Java中的锁可以用来保护代码块、对象、方法、类等各种粒度的共享资源。
- 通过锁可以让多个线程按照特定的顺序访问共享资源,从而避免死锁、竞争条件等并发问题
- Java中常用的锁有synchronized关键字、ReentrantLock、ReadWriteLock、Semaphore等,这些锁提供了不同的功能和性能特征
分类
从并发的角度可将线程安全策略分为三种(我们日常开发主要涉及到前两种)
- 第一种是悲观锁,核心是互斥同步(synchronized,Lock体系)
- 第二种是乐观锁,核心是非阻塞同步,通过CAS进行原子类操作,即不加锁(底层为volatile+CAS)
- 第三种是无同步方案,包括可重入代码和线程本地存储
常用锁介绍
- 重入锁(ReentrantLock):可重入锁是一种可多次获取的锁,它允许一个线程在获得锁的同时再次获取锁。它提供了与synchronized关键字相同的互斥访问控制,但具有更大的灵活性和更强的功能
- 读写锁(ReadWriteLock):读写锁是一种特殊类型的锁,它允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。在读多写少的情况下,读写锁可以提高程序的并发性能
- 公平锁(FairLock):公平锁保证线程获取锁的顺序与线程请求锁的顺序相同。如果存在一个等待队列,那么等待时间最长的线程将获得锁
- 互斥锁(Mutex):互斥锁是一种最简单的锁,它通过对共享资源加锁来确保同一时间只有一个线程可以访问该资源
- 信号量(Semaphore):信号量是一种同步工具,它可以用来控制对共享资源的访问。它允许多个线程同时访问共享资源,但限制了同时访问该资源的线程数量
- 偏向锁(Biased Locking):偏向锁是一种优化手段,它可以减少多线程环境下锁的竞争。它的基本思想是在没有竞争的情况下将锁偏向于第一个获取锁的线程,从而避免其他线程竞争锁
应用场景
多线程锁是一种用于在多线程编程中保护共享资源的同步机制。如下是适合使用多线程锁的场景:
- 数据库访问:多个线程同时访问数据库可能导致数据一致性问题,使用锁可以保证数据的完整性和正确性
- 文件读写:多个线程同时读写同一个文件可能会导致文件损坏或者数据丢失,使用锁可以保证文件的完整性和正确性
- 共享内存:多个线程访问同一块共享内存时,使用锁可以保证每个线程都能正确读取或写入共享内存的数据
- 队列操作:多个线程同时对队列进行操作可能会导致数据错乱或者数据丢失,使用锁可以保证队列的操作顺序和数据的正确性
- 网络通信:多个线程同时进行网络通信时,使用锁可以保证数据传输的完整性和正确性
注意: 过多的锁使用会降低程序的性能。在使用锁的时候应该注意权衡锁的粒度和性能的需求
同步机制
Synchronized
概述
- synchronized是Java中的关键字,是一种同步的悲观锁
- 常用来修饰的对象有以下几种:
- 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象
- 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象
- 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象
- 修改一个类,其作用的范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象
- 通过synchronized+wait+notify实现生产者-消费者问题(在后文并发实践篇会有相关示例)
实现原理
synchronized是基于Java对象头中的标志位实现的,其中在Java对象头中有两个标志位用于存储synchronized锁的信息:
- 一个是表示当前对象是否被锁定的标志位
- 一个是表示持有锁的线程的标识符
执行过程描述
- 当一个线程尝试获得一个被synchronized锁保护的资源时(即执行到monitorenter指令时),JVM会首先检查该对象的锁标志位,如果锁标志位为0表示该对象没有被锁定,JVM会将锁标志位设置为1,并将持有锁的线程标识符设置为当前线程的标识符。如果锁标志位为1表示该对象已经被其他线程锁定,当前线程会进入阻塞状态,等待其他线程释放锁;
- 当一个线程释放一个被synchronized锁保护的资源时(即执行到monitorexit指令时),JVM会将锁标志位设置为0并且清空线程id释放该对象,同时JVM会唤醒等待该对象锁的其他线程,使它们可以继续竞争锁
monitor指令
通过反编译字节码文件后发现synchronized底层借助monitor指令实现同步,;monitor指令包括monitorenter和monitorexit可以理解为代码开始同步/开始加锁和结束同步/结束加锁;
- monitorenter指令进行加锁: 进入同步代码后,每次进行操作前后,都需要获取最新的数据,执行完毕,及时写回主内存
- monitorexit指令进行释放锁: 设置对象的锁标志为0,线程id清空,唤醒等待该对象锁的其他线程,使它们可以继续竞争锁
获取TestMultiThread单例对象(使用了DCL)
javap -v .\TestMultiThread.class(
对编译后的类进行反编译)
注意:monitorexit指令为何出现2次?
- 第一个monitorexit指令是同步代码块正常释放锁的一个标志
- 如果同步代码块中出现Exception或者Error,则会调用第二个monitorexit指令来保证释放锁
锁优化
概述
JDK5升级到JDK6后一项重要的改进项,HotSpot虚拟机开发团队花费了大量的资源去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、偏向锁(Biased Locking)、轻量级锁(Lightweight Locking)等,这些技术都是为了在线程之间更高效的共享数据及解决竞争问题,从而提高程序的执行效率。
锁粗化
假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部
锁消除
锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
- 比如StringBuffer.append()方法使用了synchronized关键字来进行线程安全的保护.但若仅在线程内部把StringBuffer的对象当作一个局部变量来使用,其实就不会发生所谓的线程不安全的情况.此时Java以Server模式启动的,且已经开启了逃逸分析的配置,那么编译器就会将这段代码优化, 锁消除
偏向锁和轻量级锁
- 偏向锁:在无竞争的情况下把整个同步都消除掉,也无CAS操作。简单的讲,就是在锁对象的对象头中有个ThreadId字段,这个字段如果是空的,第一次获取锁时将自身的ThreadId写入到锁的ThreadId字段内,将锁头内的是否偏向锁的状态置为1(上面的标识位),此后获取锁时直接检查ThreadId是否和自身线程Id一致,若一致则认为当前线程已经获取了锁。但当锁有竞争关系的时候,需要解除偏向锁,使锁进入竞争的状态(目前JDK偏向锁默认是开启的)
- 轻量级锁:在无竞争的情况下使用CAS操作对象头,将替换线程ID和指向锁记录的指针。成功则获得锁,失败则自旋等待获得锁。机制:每个锁都关联一个请求计数器和一个占有他的线程,当请求计数器为0时,这个锁可以被认为是unhled的,当一个线程请求一个unheld的锁时,JVM记录锁的拥有者,并把锁的请求计数加1,如果同一个线程再次请求这个锁时,请求计数器就会增加,当该线程退出syncronized块时,计数器减1,当计数器为0时锁被释放(这就保证了锁是可重入的,不会发生死锁的情况)
锁升级
概述
JDK1.6之后对synchronized进行了性能上的优化,引入了轻量级锁和偏向锁来减少性能消耗,所以不完全认为它是一个重量级锁,锁升级的过程是由JVM
自动完成,JVM
会根据同步竞争的情况来自动
选择合适的锁级别,以提供更好的性能和效率。JDK1.6中锁有四种状态,分别是无锁、轻量级锁(自旋)、偏向锁、重量级。锁升级过程从偏向锁->轻量级锁->重量级锁,而且锁升级之后不可降级
锁升级过程
当第一个线程访问同步块时,JVM将该线程ID记录在对象头部,并将对象的标记状态设置为偏向锁(偏向锁发生于同一时刻只有一个线程竞争锁的场景)。若有多个线程同时竞争锁,则偏向锁会升级为轻量级锁。如果线程的 CAS 自旋操作达到一定次数仍未竞争到锁,则轻量级锁会升级为重量级锁
- 初始状态:对象没有锁标记,即为无锁状态
- 偏向锁申请:当第一个线程访问同步块时,JVM将该线程ID记录在对象头部,并将对象的标记状态设置为偏向锁
- 偏向锁撤销:当其他线程尝试获取锁时,发现对象的偏向锁被占用,会撤销偏向锁,升级为轻量级锁
- 轻量级锁(Lightweight Locking): 轻量级锁是指当多个线程轻度竞争同步块时,JVM会将对象的锁记录存储在线程的栈帧中,而不是在对象头中。线程在进入同步块之前,通过CAS(比较并交换)自旋操作尝试获取锁。如果CAS自旋操作成功则表示获取锁成功,进入同步块,则当前锁仍然处于轻量级锁状态;如果CAS失败表示存在竞争,升级为重量级锁
- 重量级锁是指当多个线程激烈竞争同步块时,JVM会将对象的锁升级为重量级锁,使用操作系统提供的互斥量来实现锁机制。重量级锁涉及到线程的阻塞和唤醒操作,开销较大
volatile
概述
volitate是JVM提供的轻量级同步机制关键字。volatile相比于synchronized(重量级锁),它属于是Java提供的一种轻量级的同步机制,因为它不会引起线程上下文的切换和调度;但无法保证线程安全
特点
- 保证可见性(缓存一致性原理)
- 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去
- 这个写会操作会导致其他线程中的volatile变量缓存无效
- 保证有序性
- 通过内存屏障相关指令(lock指令)禁止指令重排实现有序性(重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段,在单线程下一定能保证结果的正确性,但在多线程环境下结果不一定正确)
- 内存屏障作用
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成
- 会强制将对缓存(线程中私有的工作内存)的修改操作立即写入主存(堆内存)
- 如果是写操作,它会导致其他线程中对应的缓存行无效
- 无法保证原子性
- volatile不适合复合操作(如volatile++),就是因为无法保证原子性
常见使用场景
- 状态量标记,如:volatile bool flag = false;对变量的读写操作,标记为volatile可以保证变量的修改对线程立刻可见,比synchronized,Lock实现有一定的效率提升
- 单例模式中通过使用典型的双重检查锁定(DCL)保证线程安全示例
//懒汉单例模式
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) { //将同步的粒度降到方法内部,提高了程序的性能
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}