Java进阶 - 并发编程
Java的锁设计
在计算机科学中,锁(lock)或互斥(mutex)是一种同步机制,用于在有许多执行线程的环境中强制对资源的访问限制。锁旨在强制实施互斥排他、并发控制策略。了解锁的三个概念:
- 锁开销(lock overhead),锁占用内存空间、cpu初始化和销毁锁、获取和释放锁的时间。程序使用的锁越多,相应的锁开销越大。
- 锁竞争(lock contention),一个进程或线程试图获取另一个进程或线程持有的锁,就会发生锁竞争。锁粒度越小,发生锁竞争的可能性就越小。
- 死锁(deadlock),至少两个任务中的每一个都等待另一个任务持有的锁的情况。
乐观锁和悲观锁
- 【悲观锁】线程每次要操作共享数据前都会假设其他线程也可能会操作这个数据,所以每次操作前都会加锁,这样其他线程操作会被堵塞。Java中的synchronized和ReententLock等就是典型的悲观锁,还有一些使用了synchronized关键字的容器类如HashTable等也是悲观锁的应用。
- 【乐观锁】认为读多写少,遇到并发写的可能性低。乐观锁操作数据是不会上锁,在更新的时候会判断一下此期间是否有其他线程去更新这个数据。
乐观锁可以使用版本号机制和CAS算法实现。在java语言中,java.util.concurrent.atomic包下的原子类就是使用CAS乐观锁实现的。
两种锁的使用场景:
- 悲观锁和乐观锁没有孰优孰劣,有其各自适应的场景。
- 乐观锁适用于写比较少(冲突较小)的场景,因为不用上锁、释放锁,省去了锁的开销,从而提高了吞吐量。
- 如果是写多读少的场景,即冲突比较严重,线程间竞争激励,使用乐观锁就是导致线程不断进行重试,这样可能还是降低性能,这种场景下使用悲观锁比乐观锁就比较适合。
独占锁和共享锁
- 【独占锁】是指一次只能被一个线程所持有。如果一个线程对数据加上了排它锁后,那么其他线程就不能再对该数据加任何类型的锁。获得独占锁的线程既能读取数据又能修改数据。
JDK中的synchronized和java.util.concurrent(JUC)包中Lock的实现类就是独占锁。
- 【共享锁】是指锁可被多个线程持有,如果一个线程对数据加上了共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读取数据,不能修改数据。JDK中ReentrantReadwriteLock就是一种共享锁。
互斥锁和读写锁
- 【互斥锁】就是独占锁的一种常规类实现,是指某一资源同时只允许另一个访问者对齐进行访问,具有唯一性和排他性。互斥锁一次只能一个线程拥有互斥锁,其他线程只能等待。
- 【读写锁】是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。读锁可以在没有写锁的时候被多个线程同时拥有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。
公平锁和非公平锁
- 【公平锁】是指多个线程按照申请锁的顺序获取锁,类似于排队买票,先来的人先买,后来的人后买。
- 【非公平锁】是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境可能赵成优先级翻转,或者饥饿的状态(某个线程一直得不到锁)。
在Java中synchronized关键字是非公平锁,ReentrantLock默认也是非公平锁。
可重入锁
可重入锁又称之为递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。在Java ReentrantLock而言,他的名字就可以看出是一个可重入锁。对于Synchronize而言,也是一个可重入锁。注意:可重入锁的一个好处是可一定程度避免死锁。
在上面的代码中mehtodA调用mehtodB,如果一个线程调用了mehtodA已经获取了锁再去调用mehtodB就不需要再次去获取锁了,这就是可重入锁的特性。如果不是可重入锁的话mehtodB可能不会被当前线程执行,可能造成死锁。
自旋锁
自旋锁是指线程在没有获取锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋。目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。(默认10次)
如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体的性能。因此自旋锁不是适应锁占用时间长的并发情况的。
例如在Java中,AtomicInteger 类有自旋的操作:
另外,在JDK1.6又引入了自适应自旋,这个就比较智能了,自旋时间不再固定,由前一次在同一个锁上的时间以及锁的拥有者的状态来决定。如果虚拟机认为这次自旋也很有可能再次成功那就会次序较多的时间,如果自旋很少成功,那以后可能就直接省略调自旋过程,避免浪费处理器自旋。
分段锁(Segmentation)
分段锁是一种锁的设计策略,并不是一种具体锁。通过将数据分成多个段并使用锁来保护每个段,允许多个线程同时访问不同的段,而不会相互阻塞。它能够提高并发性能,但实现较为复杂,需要谨慎处理多个段之间的同步和通信。
CurrentHashMap底层就用了分段锁,它不是对整个数据结构加锁,而是通过hashcode来判断该元素应该存放的分段,然后对这个分段进行加锁。这种策略的优点在于细化锁的粒度,提高并发性能。当多个线程同时访问不同的分段时,可以实现真正的并行插入,提高并发性能。而当统计size时,需要获取所有的分段锁才能统计。
锁优化技术
锁粗化
锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。
锁消除
锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。
例如上图的test方法中三个变量s1,s2,stringBuffer,他们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算是多个线程访问test方法也是线程安全的。为了提高效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为锁消除。
synchronized
介绍
synchronized 是Java 提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。
线程的执行代码在进入synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的wait 系列方法时释放该内置锁。
特性
- 【原子性】确保线程互斥的访问同步代码,如同单线程环境,自然具有原子性。
- 【可见性】是指一个线程对共享变量进行了修改,另外一个线程可以立即读取到修改后的最新值。synchronized可见性是通过内存屏障来实现的,按可见性划分,内存屏障分为:
Load屏障:获取锁时,执行refresh,从其他处理器的高速缓冲、主内存,加载数据到自己的高速缓冲,保证数据时最新的。
Store屏障:释放锁时,执行flush操作,将自己处理器更新的变量值刷新到高速缓存、主内存去。
- 【有序性】是指代码的执行顺序,Java在编译时和运行时会对代码进行优化(重排序),会导致程序最终的执行不一定就是我们编写代码时的顺序。synchronized内的代码和外部的代码禁止排序。
- 【可重入】是指一个线程可以多次执行synchronized,重复获取同一把锁。
使用形式
synchronized主要有三种使用形式:
- 修饰普通同步方法:锁的是当前实例对象this;
- 修饰静态同步方法:锁的是当前类的Class的字节码对象;
- 修饰同步代码块:锁的括号的配置对象(可以是某个对象,也可以是某个类的.class对象);同步代码块:synchronized (this){}
类锁和对象锁互不干扰。类锁其实是一种特殊的对象锁,而由于一个类只有一把对象锁,所以同一类的不同对象使用类锁将会是同步的。
底层原理
对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
- 对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
synchronized用的锁就是存在Java对象头里的,对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。
监视器 (Monitor)
synchronized的重量级锁是通过对象内部的一个叫做监视器锁(Monitor)来实现的,效率比较低下。要操作Monitor对象,都需要操作系统来帮忙完成(因为Monitor是依赖于底层的操作系统的互斥原语mutex来实现),这会导致线程在“用户态和内核态”两个态之间来回切换,这个状态之间的转换需要相对比较长的时间,对性能有较大影响。
在JDK1.6之后Java官方对从JVM层面对synchronized较大优化,为了减少这种重量级锁的使用,引入了轻量级锁和偏向锁,这两个锁可以不依赖Monitor的操作。
任何一个对象都有一个Monitor与之关联(在Java的设计中 ,每一个Java对象都带了一把看不见的锁,也叫做Monitor锁),当且一个Monitor被持有后,它将处于锁定状态。
synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
- MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;
- MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;
锁升级
synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了4种锁的状态:无锁状态、偏向锁、轻量级锁、重量级锁,它们随着多线程竞争情况逐渐升级,但不能降级,而且这个过程就是开销逐渐加大的过程。
- 偏向锁:Java偏向锁是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多个线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁。
偏向锁的实现是通过控制对象Mark word的标志位来实现的。如果当前是偏向锁状态,需要进一步判断对象头存储的线程ID是否与当前线程ID一致,如果一致直接进入。
- 轻量级锁:当线程竞争变的比较激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下的竞争的成都很低,通过自旋的方式等待上一个线程释放锁。
- 重量级锁:如果线程并发进一步加剧,线程的自旋超过了一次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,重量级锁会使除了此时拥有的锁的线程以外的线程都堵塞。
升级到重量级锁其实就是互斥锁了,一个线程拿到了锁,其余线程都会处于阻塞等待状态。
volatile
Java中的volatile关键字是一个非常重要的关键字,它主要用于多线程编程中,用于保证共享变量的可见性和原子性。
public volatile int count = 0;
- 【可见性】当一个线程修改了volatile修饰的变量的值,其他线程可以立即看到这个修改,保证了共享变量的可见性。
- 【原子性】volatile可以保证一些简单的操作的原子性,例如++操作,但是对于复合操作,volatile关键字无法保证原子性。
- 【禁止指令重排序】编译器和处理器在编译和执行代码时,可能会对指令进行重排序,但是volatile关键字可以禁止这种重排序,保证了程序的正确性。
和synchronized的区别
- volatile关键字保证了共享变量的可见性和禁止指令重排序,但是无法保证原子性,而synchronized关键字可以保证原子性、有序性和可见性。
- volatile关键字适用于一些简单的操作,例如++操作,而synchronized关键字适用于复合操作。
- volatile关键字不会造成线程阻塞,而synchronized关键字可能会造成线程阻塞。
Lock
Lock接口是Java提供的一种线程同步机制,它允许线程以排他性的方式访问共享资源。与synchronized关键字不同,Lock接口提供了更灵活的锁定和解锁操作,和一些高级特性,如条件变量(Condition接口)、超时获取锁(tryLock方法)等。
Condition (条件变量)
Condition 接口通常与 ReentrantLock 一起使用,可以为每个 ReentrantLock 创建多个条件(Condition),每个条件可以控制一组线程的等待和唤醒。
它定义了一些重要的方法,用于线程的等待和唤醒:
- await():使当前线程等待,并释放锁,直到其他线程调用相同条件上的 signal() 或 signalAll() 方法来唤醒它。
- awaitUninterruptibly():与 await() 类似,但不响应中断。
- signal():唤醒一个在该条件上等待的线程。如果有多个线程在等待,只会唤醒其中一个,具体唤醒哪个线程不确定。
- signalAll():唤醒所有在该条件上等待的线程。
ReentrantLock (可重入锁)
ReentrantLock称为可重入锁,是 Lock 接口的主要实现类,它具有完全与 synchronized 相同的并发性和内存语义,同时还添加了锁投票、定时锁等候和中断锁等候的一些功能。能够实现比synchronized更细粒度的控制,比如控制公平性。
此外需要注意,调用lock()之后,必须调用unlock()释放锁。
和synchronized的区别
synchronized | Lock |
自动释放锁 | 必须手动调用unlock()方法释放锁 |
不知道线程是否拿到锁 | 可以知道线程是否拿到锁 |
能锁住方法和代码块 | 只能锁住代码块 |
读,写操作都堵塞 | 可以使用读锁,提高线程的读效率 |
非公平锁 | ReentrantLock 默认为非公平锁,也可以手动指定为公平锁 |
JVM 层面通过监视器实现 | ReentrantLock 是基于 AQS 实现 |
不能响应中断 | ReentrantLock 可以响应中断,解决死锁的问题。 |
CAS算法(Compare and Swap)
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。
Atomic(原子操作包)
JUC 并发包中原子类, 都存放在 java.util.concurrent.atomic 类路径下。根据操作的目标数据类型,可以将包中的原子类分为 4 类:
基本原子类 | AtomicInteger | 整型原子类 |
AtomicLong | 长整型原子类 | |
AtomicBoolean | 布尔型原子类 | |
数组原子类 (通过原子方式更数组里的某个元素的值) | AtomicIntegerArray | 整型数组原子类 |
AtomicLongArray | 长整型数组原子类 | |
AtomicReferenceArray | 引用类型数组原子类 | |
引用原子类 | AtomicReference | 引用类型原子类 |
AtomicMarkableReference | 带有更新标记位的原子引用类型 | |
AtomicStampedReference | 带有更新版本号的原子引用类型 |
Unsafe类
通过源码我们发现AtomicInteger的增减操作都调用了Unsafe 实例的方法,Unsafe 是位于 sun.misc 包下的一个类,Unsafe 提供了CAS 方法,直接通过native 方式(封装 C++代码)调用了底层的 CPU 指令 cmpxchg。
CAS的缺点
- ABA问题。
因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。JDK 提供了两个类 AtomicStampedReference、AtomicMarkableReference 来解决 ABA 问题。
- 只能保证一个共享变量的原子操作。
一个比较简单的规避方法为:把多个共享变量合并成一个共享变量来操作。 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个 AtomicReference 实例后再进行 CAS 操作。比如有两个共享变量 i=1、j=2,可以将二者合并成一个对象,然后用 CAS 来操作该合并对象的 AtomicReference 引用。
- 循环时间长开销大。
高并发下N多线程同时去操作一个变量,会造成大量线程CAS失败,然后处于自旋状态,导致严重浪费CPU资源,降低了并发性。解决 CAS 恶性空自旋的较为常见的方案为:
- 分散操作热点,使用 LongAdder 替代基础原子类 AtomicLong。
- 使用队列削峰,将发生 CAS 争用的线程加入一个队列中排队,降低 CAS 争用的激烈程度。JUC 中非常重要的基础类 AQS(抽象队列同步器)就是这么做的。
Semaphore (信号灯)
Semaphore(信号灯)是java5的新特性,仍然位于java.util.concurrent下。 Semaphore 可以很轻松完成信号量控制,Semaphore分为单值和多值。单值只允许一个线程访问,多值允许多个线程同时访问。
在信号量上我们定义两种操作: acquire(获取许可)和 release(释放)。信号量是一个非负整数,用来管理一定数量的许可证。每个线程在访问共享资源之前,需要先获取一个许可证,如果许可证已经被其他线程占用,则需要等待,直到许可证可用。当线程使用完共享资源后,需要释放许可证,使其他线程可以继续访问。
缺点:当线程死锁时,永远没法释放,导致一直阻塞。
new Semaphore(int permits, boolean fair) //permits为可通过的线程数,fair为是否为公平模式
常见应用场景:
- 有限资源的并发控制:可以限制对有限资源的并发访问,例如数据库连接池或线程池中的资源管理。
- 控制并发线程数:可以控制同时执行的线程数量,例如限制同时访问某个接口的请求数量。
- 实现互斥锁:可以用于实现互斥锁的功能,通过设置permits为1,保证同一时间只有一个线程可以访问临界区。
- 控制任务流量:可以限制任务的执行速率,例如限制某个任务在单位时间内的执行次数。
CountDownLatch (倒计时屏障)
介绍
CountDownLatch 是 Java 中的一个并发工具类,用于协调多个线程之间的同步。其作用是让某一个线程等待多个线程的操作完成之后再执行。它可以使一个或多个线程等待一组事件的发生,而其他的线程则可以触发这组事件。
- CountDownLatch 可以用于控制一个或多个线程等待多个任务完成后再执行。
- CountDownLatch 的计数器只能够被减少,不能够被增加。
- CountDownLatch 的计数器初始值为正整数,每次调用 countDown() 方法会将计数器减 1,计数器为 0 时,等待线程开始执行。
- CountDownLatch 的计数器是线程安全的,多个线程可以同时调用 countDown() 方法,而不会产生冲突。
原理
CountDownLatch 的实现原理比较简单,它主要依赖于 AQS(AbstractQueuedSynchronizer)框架来实现线程的同步。CountDownLatch 内部维护了一个计数器,该计数器初始值为 N,代表需要等待的线程数目,当一个线程完成了需要等待的任务后,就会调用 countDown() 方法将计数器减 1,当计数器的值为 0 时,等待的线程就会开始执行。
CyclicBarrier (循环屏障)
介绍
CyclicBarrier(循环屏障),它是一个同步助手工具,它允许多个线程在执行完相应的操作之后彼此等待共同到达一个障点(barrier point)。CyclicBarrier 也非常适合用于某个串行化任务被分拆成若干个并行执行的子任务,当所有的子任务都执行结束之后再继续接下来的工作。
CyclicBarrier 使一定数量的线程反复地在屏障位置处汇集。当线程到达屏障位置时将调用 await 方法,这个方法将会阻塞,直到所有线程都到达屏障位置,当所有线程都到达屏障位置,那么屏障将打开,此时所有的线程都将被唤醒,而屏障将被重置以便下次使用。
源码解析
通过 CyclicBarrier 代码结构我们可以发现其内部使用了独占锁 ReentrantLock 和 Condition 来实现线程之间的等待通知功能。它一共提供了两个构造方法,分别如下:
- CyclicBarrier(int parties):参数 parties 表示屏障拦截的线程数量,必须有 parties 个线程调用了 await() 方法后此屏障才会被打开。
- CyclicBarrier(int parties, Runnable barrierAction):参数 parties 同上。参数 barrierAction 表示当所有线程都到达屏障点后,需要执行的任务。因为 barrierAction 中的代码是在最后一个到达屏障点的线程中执行,建议不要在此内部实现耗时较大的任务业务逻辑。
与CyclicBarrier区别
CountDownLatch | CyclicBarrier | |
实现方式 | 由Lock和Condition实现 | 由同步控制器AQS(AbstractQueuedSynchronizer)实现 |
await方法 | 等待计数器被count down到0 | await方法的线程将会等待其他线程到达barrier point |
参与线程角色 | 负责倒计时和等待倒计时的线程都可以有多个,用于不同角色线程间的同步 | 参与线程角色是一样的,用于同一角色线程间的协调一致 |
重复使用 | 不可重复使用 | 内部的计数器count是可被重置的,所以可重复使用 |
ThreadLocal
简介
当使用ThreadLocal维护变量的时候,它为每一个使用该变量的线程提供一个独立的变量副本,即每个线程内部都会有一个该变量,这样同时多个线程访问该变量并不会彼此相互影响,既不存在线程安全问题,也不会影响程序的执行性能。
ThreadLocal 变量通常被private static修饰。这里需要注意:
- 因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来。
- 既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题。
内部方法
ThreadLocal 可以存储任何类型的变量对象, get返回的是一个Object对象,但是我们可以通过泛型来制定存储对象的类型。
【强制】必须回收自定义的ThreadLocal变量,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的 ThreadLocal变量,可能会影响后续业务逻辑和造成内存泄露等问题。尽量在代理中使用try-finally块进行回收。
与Synchronized的区别
ThreadLocal<T>其实是与线程绑定的一个变量。ThreadLocal和Synchonized都用于解决多线程并发访问。但是ThreadLocal与synchronized有本质的区别:
- Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。
- Synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。而Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。
TransmittableThreadLocal
参考资料:
JAVA并发编程(六):线程本地变量ThreadLocal与TransmittableThreadLocal_threadlocal 与 transmittablethreadlocal-CSDN博客