27Java多线程之线程间通信
合理的使用Java多线程可以更好地利用服务器资源。一般来讲,线程内部有自己私有的线程上下文,互不干扰。但是当需要多个线程间相互协作时,就需要掌握线程的通信方式。
合理的使用Java多线程可以更好地利用服务器资源。一般来讲,线程内部有自己私有的线程上下文,互不干扰。但是当需要多个线程间相互协作时,就需要掌握线程的通信方式。
原文链接:https://www.cnblogs.com/aspirant/p/11470858.html
synchronized 不管是读还是写,如果前面有锁,只能是等待,
Lock中有读写锁,可以做到读读并发,读写互斥,写写互斥,但是synchronized做不到
Java中锁的分类有:
上面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词进行一定的解释。
synchronized | Lock | |
---|---|---|
别名 | 隐式锁 | 显式锁 |
实现方式 | Java中的关键字,语言内置实现 | 是一个接口 |
锁的释放方式 | 同步代码执行结束或抛出异常时自动释放 | finally 块中手动释放 |
多个锁嵌套的释放顺序 | 依次获取(释放)锁, 最先获取的最后释放 |
自由获取(释放)锁 |
发生异常时 | 自动释放线程占有的锁,不会导致死锁 | 需在 finally 块中手动释放锁,否则会导致死锁 |
定时阻塞获取锁 | 不支持 | 支持 |
判断是否已成功获取锁 | 无法办到 | tryLock() 返回值是 boolean |
性能 | 竞争激烈时(大量线程同时获取锁),性能更优 | |
可中断锁 (获取锁时能否响应线程中断指令) |
非 | 是tryLock(long time, TimeUnit unit) lockInterruptibly() |
可重入锁 | 是 | 是 |
公平锁 | 非公平锁,无法判断锁是否公平 | 默认非公平锁。 实现类的构造方法可指定锁的公平性。isFair() 判断锁是否公平。 |
独占锁(互斥锁)/共享锁 | 独占锁(互斥锁) | 独占锁:synchronized、ReentrantLock、ReentrantReadWriteLock.WriteLock 共享锁:ReentrantReadWriteLock.ReadLock 通过 AQS(AbstractQueuedSynchronizer)实现独占和共享。 |
读写锁 | 无 | ReentrantReadWriteLock.ReadLock ReentrantReadWriteLock.WriteLock |
中断锁指的是可中断获取锁的等待状态,也就是在获取锁时可以响应线程中断指令,从而中断获取锁。
如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。
在 Java 中,synchronized 是不可中断锁,Lock 是可中断锁。
reentrant :可重入; 可重入的; 重入; 可再入的; 重进入;
可重入锁又名递归锁。特点:
如果不设计成可重入锁,反复给自己加锁就会产生死锁。
锁类型 | 锁对象 |
---|---|
可重入锁 | synchronized |
可重入锁 | ReentrantLock(Re entrant Lock 重新进入锁) |
可重入锁 | ReentrantReadWriteLock.ReadLock |
可重入锁 | ReentrantReadWriteLock.WriteLock |
不可重入锁 | 没有内置对象,需自定义 |
1 | public class Test { |
实际开发中几乎不会用到。
1 | public class Test { |
是否公平:指的是每个线程获取锁的机会是否平等。
公平锁:多个线程按照申请锁的顺序来获取锁。比如同时有多个线程在等待同一个锁,当这个锁被释放时,等待时间最久的线程(最先申请的线程)会获得该锁。
非公平锁:多个线程争抢同一个锁时,有可能后申请的线程比先申请的线程优先获取锁。可能造成优先级反转或者饥饿现象(导致某些线程永远获取不到锁)。优点是比公平锁吞吐量大。
对于 synchronized
而言,也是一种非公平锁。由于其并不像 ReentrantLock
是通过 AQS(AbstractQueuedSynchronizer) 的来实现线程调度,所以并没有任何办法使其变成公平锁。
对于 ReentrantLock
和 ReentrantReadWriteLock
实现而言,默认是非公平锁,构造方法可指定锁的公平性。
在 ReentrantLock 和 ReentrantReadWriteLock 中都定义了两个静态内部类,分别用来实现非公平锁和公平锁。可以在创建对象时,通过构造方法指定锁的公平性:
1 | public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { |
下面示例中,每次都是 Thread-0 拿到锁,验证了 synchronized 是非公平锁。
1 | public class Test { |
修改上面示例 Task 类如下,验证 Lock 的非公平锁:
1 | class Task implements Runnable { |
修改上面示例 private Lock lock = new ReentrantLock(true);
即可。
1 | public final boolean isFair() { |
该方法只能判断 ReentrantLock、ReentrantReadWriteLock:
1 | public class Test { |
独占锁(互斥):该锁一次只能被一个线程所持有。例如:synchronized、ReentrantLock、读写锁中的写锁 ReentrantReadWriteLock.WriteLock
共享锁(非互斥):该锁可被多个线程所持有。例如:读写锁中的读锁 ReentrantReadWriteLock.ReadLock,但读写、写读 、写写的过程是互斥的。
Lock 锁的 独占 与 共享 是通过 AQS(AbstractQueuedSynchronizer)来实现的。
1 | public class Test { |
修改上述代码:
1 | class Task implements Runnable { |
乐观锁与悲观锁指的不是锁类型,而是指 并发策略 是乐观的还是悲观的。
悲观锁:只要编码中 利用到锁,并发策略就是悲观的。
乐观锁:无锁编程,通常采用 CAS 等原子操作来实现。典型的例子就是原子类,通过 CAS 自旋实现原子操作。
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,避免线程上下文切换带来的开销。但由于获取锁的线程一直处在运行状态,所以不适合单核单线程的CPU。
通常采用 CAS 等原子操作来实现,可以参考 自旋锁的实现 。
这三种锁是指锁的状态,并且是针对 synchronized
。在 Java 5 通过引入锁升级的机制来实现高效 synchronized
。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap
而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
我们以 ConcurrentHashMap
来说一下分段锁的含义以及设计思想,ConcurrentHashMap
中的分段锁称为 Segment,它即类似于 HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个 Entry 数组,数组中的每个元素又是一个链表;同时又是一个 ReentrantLock(Segment 继承了 ReentrantLock)。
当需要 put 元素的时候,并不是对整个 HashMap 进行加锁,而是先通过 hashcode 来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程 put 的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计 size 的时候,就是获取 HashMap 全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
推荐链接:
死锁:两个(或两个以上)线程在执行的过程中,因争夺资源产生的一种互相等待现象。
产生条件:
1 | public class Test { |
略
1 | public class Test { |
1 | public class Test { |
有时候继承 Thread 类会更方便。
1 | public class Test { |
Java中与线程安全有关的类:安全的集合类、java.util.concurrent
JUC包下类 等。
线程安全(Thread-safe)的集合对象:
如何把一个线程不安全的集合类变成一个线程安全的集合类?
1 | public class ThreadDemo { |
StringBuffer
包含三个操作数:当前内存值、预期原值、新值,如果内存值和预期原值匹配,就将内存值更改为新值;否则什么也不做。
1 | public class Test { |
假如一个值原来是A,现在变成了B,后来又变成了A,那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。
AtomicMarkableReference 是一个带修改标记的引用类型原子类。
AtomicStampedReference 是一个带版本号的引用类型原子类。
JUC.locks包
1 | public interface Lock { |
java.util.concurrent.locks.ReentrantLock
这个是 JDK @since 1.5 添加的一种颗粒度更小的锁,它完全可以替代 synchronized 关键字来实现它的所有功能,而且 ReentrantLock 锁的灵活度要远远大于 synchronized 关键字,它更清晰的表达了如何加锁和释放锁。
获取锁,平常使用最多的一个方法。
如果其他线程没有持有锁,则获取该锁并立即返回,将锁持有计数设置为 1。
如果当前线程已经持有锁,那么持有计数加一并且该方法立即返回。
如果该锁被另一个线程持有,那么当前线程将因线程调度目的而被禁用并处于休眠状态(等待获取锁),直到获得该锁为止,此时锁持有计数设置为 1。
注意:
1 | private final Lock lock = new XxxLock(); |
示例1:
1 | public class Test { |
示例2:
1 | class SellTicket implements Runnable { |
tryLock()
有返回值,它表示用来尝试获取锁。
如果其他线程没有持有锁,则获取该锁并立即返回true值,将锁持有计数设置为 1。即使此锁已设置为使用公平排序策略,调用tryLock()将立即获取可用的锁,无论其他线程当前是否正在等待该锁。这种“闯入”行为在某些情况下很有用,即使它破坏了公平。 如果你想尊重这个锁的公平性设置,那么使用 tryLock(0, TimeUnit.SECONDS)
这几乎是等效的(它也检测中断)。
如果当前线程已持有此锁,则持有计数将增加 1 并且该方法返回 true。
如果锁被另一个线程持有,那么这个方法将立即返回 false 值。
1 | private final Lock lock = new XxxLock(); |
如果在给定的等待时间内成功获取锁(没有被另一个线程持有),且当前线程没有被中断,则返回true,将锁持有计数设置为 1。
由于该方法的声明中抛出了异常,所以它必须放在try块中或者在调用的方法上抛出 InterruptedException
。
1 | private final Lock lock = new XxxLock(); |
示例:
1 | public class Test { |
这个方法比较特殊,当通过该方法获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就使说,当两个线程同时使用 lock.lockInterruptibly()
获取某个锁时,假若线程A获取到了锁,线程B在等待,那么对线程B调用 threadB.interrupt()
方法能够中断线程B的等待过程。
由于该方法的声明中抛出了异常,所以它必须放在try块中或者在调用的方法上抛出 InterruptedException
。
1 | private final Lock lock = new XxxLock(); |
示例:
1 | public class Test { |
1 | public final boolean isFair() // 判断锁是否是公平锁 |
1 | public interface ReadWriteLock { |
ReentrantReadWriteLock 实现了 ReadWriteLock 接口(非 Lock 接口),调用该接口的 readLock()
和 writeLock()
方法可分别获取读和写的 Lock 锁。
ReentrantReadWriteLock 还提供了丰富的用于监视系统状态的方法。
为什么要分两把锁?
因为读操作的锁,要支持被多个线程获取(多线程同时读),这样可以提升读操作的效率。而写操作的锁,只能被一个线程获取。
读锁被占用时,无法申请写锁,但能申请读锁。
写锁被占用时,无法申请写锁,无法申请读锁。
读读的过程是共享的,读写、写读 、写写 的过程是互斥的。
1 | public class Test { |
输出:
1 | Thread-3读取 >>> 固定key: Thread-0写入1 |
读锁 | 写锁 | |
---|---|---|
读锁 | 可重入 | 不可重入 |
写锁 | 不可重入 | 不可重入 |
和 StampedLock 相同。验证读写不可重入:
1 | public class Test { |
实际开发中用的不多。可以用 LockSupport实现互斥锁(等待唤醒机制)。
方法介绍:
1 | public class LockSupport { |
示例
1 | public class Test { |
读写锁存在线程饥饿的问题。饥饿指的是某一线程或多个线程因为某些原因一直获取不到资源,导致程序一直无法执行。场景举例:
1、某一线程因优先级太低导致一直分配不到资源。
2、某一线程一直占着某种资源不放,导致其他线程长期无法执行。如:读(写)操作长时间占用读(写)锁,导致写(读)操作一直等待。
如何解决线程饥饿问题:
1、与死锁相比,饥饿现象有可能在一段时间后恢复执行。可以设置合适的线程优先级,来尽量避免饥饿的产生。
2、使用读写锁的升级版 StampedLock 来解决线程饥饿。
ReadWriteLock
可以解决多线程同时读,但同时只能有一个线程写的问题。潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock
。
StampedLock
和ReadWriteLock
相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
方法 | 描述 |
---|---|
public StampedLock() | 只有一个无参构造 |
public long readLock() | 获取读锁,返回锁标识 |
public long writeLock() | 获取写锁,返回锁标识 |
public void unlock(long stamp) | 释放读写锁,参数stamp为锁标识 |
public void unlockRead(long stamp) | 释放读锁,参数stamp为锁标识 |
public void unlockWrite(long stamp) | 释放写锁,参数stamp为锁标识 |
public Lock asReadLock() | 转换为读锁 |
public Lock asWriteLock() | 转换为写锁 |
public ReadWriteLock asReadWriteLock() | 转换为读写锁 |
1 | public class Test { |
输出结果用于最后的对比
1 | null |
1 | class Data { |
输出结果
1 | null |
输出10行,发现 StampedLock 输出到260,而 ReentrantReadWriteLock 输出到 134,是否能证明饥饿问题的出现和解决呢???
和 ReentrantReadWriteLock 相同。验证读写不可重入:
1 | public class Test { |
即便不调用 remove() 也不会导致内存溢出,只不过垃圾回收器压力大些。设置 JVM 参数 -Xms30m -Xmx30m
,用 VisualVM 观察垃圾回收次数,VisualVM也会占用 JVM 内存,所以不能设置太小。
验证代码如下,观察 VisualVM 中的垃圾回收次数 collections:
1 | /** |
InheritableThreadLocal 可继承的线程本地变量。如线程B被线程A创建,B就可以获取A中的 InheritableThreadLocal。
1 | public class Test { |
https://juejin.cn/post/7214901105977671717
继承自 InheritableThreadLocal。该类由阿里提供。
java中与线程安全有关的关键字:volatile、synchronized
volatile修饰的变量不允许线程内部缓存(保证可见性),即直接修改内存,所以对其他线程是可见的。
变量经过 volatile 修饰后,对此变量进行写操作时,汇编指令中会有一个LOCK前缀指令,这个不需要过多了解,但是加了这个指令后,会引发两件事情:
即
问题来了,既然它可以保证修改的值立即能更新到主存,其他线程也会捕捉到被修改后的值,那么为什么不能保证原子性呢?
比如
volatile int a = 0;
之后有一个操作a++;
这个变量a具有可见性,但是a++
依然是一个非原子操作,也就是这个操作同样存在线程安全问题。Java中只有对基本类型变量的赋值和读取是原子操作,如
i = 1;
的赋值操作,但是像j = i;
或者i++;
这样的操作都不是原子操作,存在线程安全问题。比如先读取i
的值,再将i
的值赋值给j
,两个原子操作加起来就不是原子操作了。所以,一个变量被volatile修饰,保证了每次读取的变量值是最新的。像变量自增这样的非原子操作,这个变量就不具有原子性了。
volatile 规定 禁止指令重排序
,为了保证数据的一致性。
示例1
1 | public class Test { |
输出:
1 | ThreadA开始执行... |
示例2
示例1变形,改变 while 循环体内容
1 | public class Test { |
输出:
1 | ThreadA开始执行... |
示例3
继续改变 while 循环体内容
1 | public class Test { |
输出:
1 | ThreadA开始执行... |
总结
有以上 3 个示例可得出,flag 的可见性会根据 while 循环体内容儿变化,不知道为什么,请指教。
所以为了保证 flag 可见,用 volatile 修饰。
1 | public class Test { |
输出:
1 | main final num result = 17114 |
三次执行,都是不同的结果。
为什么会出现这种呢?因为 num++
被拆分成3个指令:
当多个线程并发执行 putfield 指令的时候,会出现写回主内存覆盖问题,所以才会导致最终结果不为 20000,所以 volatile 不能保证原子性。
sychronized 采取的同步策略是互斥同步。
在每次获取资源之前,都需要检查是否有线程占用该资源。已经进入的线程尚未执行完,将会阻塞后面其他线程。
同步锁的本质是对象实例。多线程安全需保证每个线程使用的是同一个锁对象。
同步锁保证多线程下的每个操作都是原子性的。
同步锁、同步监听对象、同步监视器、互斥锁 它们都是同步锁,说的是一个意思。
弊端:当线程相当多时,因为每个线程都会去判断同步上的锁,这是很耗费资源的,无形中会降低程序的运行效率。
1、对象是什么?
我们可以随便创建一个对象试试。
2、需要同步的代码是哪些?
把多条语句操作共享数据的代码部分给包起来
注意:
同步可以解决安全问题的根本原因就在那个对象上。该对象如同锁的功能。
这里的锁对象可以是任意对象。多个线程必须是同一把锁。
格式:
1 | synchronized(对象) { // 对象锁 |
优化 SellTicket :
1 | public class SellTicketDemo { |
把同步加在方法上。
这里的锁对象是this
1 | public class SellTicketDemo { |
把同步加在方法上。只需要在同步方法上加一个 static。
这里的锁对象是当前类的Class对象
1 | public class SellTicketDemo { |
1 | public class Singleton { |
原子是世界上的最小单位,具有不可分割性。
比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。
非原子操作都会存在线程安全问题,需要我们使用同步技术(synchronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。
如果把一个事务看作是一个程序,要么全部执行,要么全不执行。这种特性就叫原子性。
在 Java 中 synchronized 和在 lock、unlock 中的操作保证了原子性。
concurrent 包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。
扩展链接:
[Redis的单个操作是原子性的,多个操作支持事务](https://www.runoob.com/redis/redis-intro.html#:~:text=原子 – Redis的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。)
可见性与Java的内存模型有关,模型采用缓存与主存的方式对变量进行操作,也就是说,每个线程都有自己的缓存空间,对变量的操作都是在缓存中进行的,之后再将修改后的值返回到主存中,这就带来了问题,有可能一个线程修改共享变量后,还没有来的及将缓存中的变量刷新到主存,另外一个线程就对共享变量进行修改,那么这个线程拿到的值是主存中未被修改的值,这就是可见性的问题。
可见性,指线程间内存的可见性,一个线程的修改,对另一个线程立马可见。
在 Java 中 volatile、synchronized 实现可见性。
可见性问题示例:
1 | public class Test { |
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性。
volatile 是因为其本身包含 禁止指令重排序
的语义。
synchronized 是由 一个变量在同一个时刻只允许一条线程对其进行 lock 操作
这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。