并发相关
JAVA高性能内存队列-disruptor
JAVA内置队列
image.png高性能内存队列-disruptor
image.pngdisruptor为啥这么快
无锁设计
内部采用CAS方式获取下一个任务序列号,没有锁竞争,不需要线程上下文切换
伪共享问题解决
当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能。
image.png
如何解决?
- 缓冲行填充,增大数组元素的间隔使得不同线程存取的元素位于不同的缓存行上,以空间换时间,避免伪共享。
- 缓存行大小一般是64个字节,然后在序列号变量左右各自填充7个long比那里,来确保任务序列号自己独占一个处理器缓存行。
数组实现
- 底层数组实现,下标访问,速度快,时间复杂度O(1)。
- 采用事件对象预填充数组,发布任务时只需要获取序列号上的事件对象然后绑定任务即可,可以循环利用数组中的事件对象,减少垃圾回收。
- 数组长度固定为2^n ,通过位运算,加快定位的速度。
java内存模型是什么
java内存模型和jvm内存结构不是一回事,JMM是为了解决java并发问题提供的一种解决方案。
它定义了线程和主内存之间的抽象关系。线程之间的共享变量存储在主内存中,每个线程都有自己的工作内存,工作内存中存储了该线程读写共享变量的副本。
happens-before关系
在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。
与程序员密切相关的happens-before规则如下:
1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
2、监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
3、volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
4、传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。
重排序
在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。所以当初开发者编写的代码顺序和处理器实际运行的代码程序顺序可能发生了变化,这就是重排序,重排序就可能会导致出现内存可见性问题。但是处理器可以通过插入特定类型的内存屏障来禁止指令重排序,从而解决内存可见性问题。
重排序的两个原则
- 数据依赖性:如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。
编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。 - as-if-serial:不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。
volatile 和 final
- volatile:volatile就是JMM为了解决内存可见性问题提供的关键字修饰词,当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主内存,其他线程也会直接从主内存读取该变量最新的值。该关键字就是通过内存屏障(特殊的CPU指令)来禁止指令重排序。
- final:通过finla修饰的变量也可以禁止部分指令重排序,比如在一个构造函数内对一个final修饰的变量赋值和把构造对象引用复制给其他引用,这两个操作不能重排序,还有就是初次读final修饰的变量与随后读这个变量不能重排序。
synchronized
synchronized是jvm提供的一种用来进行并发控制的多线程锁;synchronized是基于进入和退出monitor对象来实现方法同步和代码块同步的,每一个java对象都可以作为synchronized的锁对象。
synchronized三种用法
- 普通对象的同步方法,锁是当前实例对象;
- 静态同步方法,锁是当前class对象;
- 同步方法块,锁是括号里的对象;
synchronized的实现。
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。代码块同步是使用monitorenter和monitorexit指令实现的,monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。
根据虚拟机规范的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1;相应地,在执行monitorexit指令时会将锁计数器减1,当计数器被减到0时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
synchronized的优化状态
锁状态 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁状态不需要其他消耗 | 如果存在锁竞争,会带来额外锁撤销 | 适用于只有一个线程 |
轻量级锁 | 竞争线程不会阻塞,提高程序响应 | 始终得不到锁的线程,会自旋消耗CPU | 适用低延时 |
重量级锁 | 线程竞争不会自旋消耗CPU | 线程阻塞,响应时间慢 | 适用高吞吐量 |
AQS 实现原理
AQS:AbstractQueuedSynchronizer,即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),它是JUC并发包中的核心基础组件。
AQS的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。作者推荐采用静态内部实现类继承AQS抽象类,并通过组合模式来实现锁的功能。AQS其实就是模板模式的实现。
AQS主要提供了两类方法来实现同步器功能
//独享式的尝试获取
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
//独享式的尝试释放
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
// 共享式的尝试获取
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
// 共享式的尝试释放
protected boolean tryReleaseShared(int arg) {
throw new UnsupportedOperationException();
}
// 判断是否为独占式
protected boolean isHeldExclusively() {
throw new UnsupportedOperationException();
}
- 独享式的模板方法主要用于构建互斥锁,例如ReentrantLock
- 共享式的模板方法主要用于构建共享式的锁,例如Semaphore,CountDownLatch,ReadWriteLock;
AQS使用一个int类型的成员变量state来表示同步状态,当state>0时表示已经获取了锁,当state = 0时表示释放了锁。它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对同步状态state进行操作,当然AQS可以确保对state的操作是安全的。
AQS通过内置的FIFO同步队列来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。
CountDownLatch VS CyclicBarrier
实现方式不同
- CountDownLatch基于AQS实现
- CyclicBarrier基于ReentrantLock实现
实现原理
-
CountDownLatch创建一个内部同步器实现AQS类,同步器中的初始state数量为counddown总数量;主线程调用await方法后,尝试通过调用内部同步器的getState方法是否为0,如果如果为0表示所有子线程已经执行完成,主线程可以继续执行,如果不为0则表示还有子线程没有执行完成,所以此时将主线程加入等待队列进行等待,子线程则通过同步队列中的共享释放模板方法对state进行减1处理,当state值为0的时候在唤醒等待队列中的主线程重新判断state的值是否为0,如果是0则直接进行后续操作,由于state值为0后,初始值则没有在修改,所以CountDownLatch实例对象只能使用一次。
-
CyclicBarrier是内部维护了一个count计数器,每当有线程调用CyclicBarrier对象await方法时,都会首先获取一个ReentrantLock,抢到锁之后对count计数器进行减1处理,然后判断count是否等于0,如果不为0;则表示还有线程没有到达,则直接调用锁条件的await方法并释放当前锁,进入等待锁条件触发状态;如果后来的线程对count计数器进行减1操作后;count值为0则表示所有线程都已经执行完成,则直接调用锁条件的singleAll方法,通知锁条件上的所有等待线程继续执行,最后重置CyclicBarrier的内部状态和计数器,进行下一轮操作。所以CyclicBarrier对象是可以多次复用的。
Callable实现原理
Callable的实现类只能配合线程池执行
在线程池中有一个submit方法,可以提交Callable类型的实现类;
但是在submit方法内部,其实是用一个FutureTask对象对Callable对象进行封装以后,在通过execute方法进行执行,execute方法只能执行Runnable类型的实现类,所以FutureTask其实也是一个Runnable接口的实现类;FutureTask 内部维护了一个volatile修饰的state状态,新创建的FutureTask实例state状态为新建状态,该状态可以在run方法中进行修改,run方法内部调用了Callable接口的call方法,其实就是使用了组合模式,execute方法执行FutureTask的run方法,其实就是执行Callable接口call方法,在call方法执行完成后,将方法返回值设置到FutureTask对象的成员变量中,然后修改执行状态为完成,最后在唤醒等待队列中的等待获取方法执行结果的线程,所以如果一个线程调用FutureTask对象的get方法,内部会先根据状态值判断方法十分执行完成,如果没有执行完成则加入阻塞队列中进行等待。
读写锁
ReentrantReadWriteLock可重入读写锁(实现ReadWriteLock接口)
使用:ReentrantReadWriteLock是ReadWriteLock接口的实现类。ReadWriteLock接口的核心方法是readLock(),writeLock()。实现了并发读、互斥写。但读锁会阻塞写锁,是悲观锁的策略。
ReentrantReadWriteLock有5个静态方法:
- Sync:继承于经典的AbstractQueuedSynchronizer(传说中的AQS),是一个抽象类,包含2个抽象方法readerShouldBlock();writerShouldBlock()
- FairSync和NonfairSync:继承于Sync,分别实现了公平/非公平锁。
- ReadLock和WriteLock:都是Lock实现类,分别实现了读、写锁。ReadLock是共享的,而WriteLock是独占的。于是Sync类覆盖了AQS中独占和共享模式的抽象方法(tryAcquire/tryAcquireShared等),用同一个等待队列来维护读/写排队线程,而用一个32位int state标示和记录读/写锁重入次数--Doug Lea把状态的高16位用作读锁,记录所有读锁重入次数之和,低16位用作写锁,记录写锁重入次数。所以无论是读锁还是写锁最多只能被持有65535次。
性能和建议:适用于读多写少的情况。性能较高。
- 公平性
非公平锁(默认),为了防止写线程饿死,规则是:当等待队列头部结点是独占模式(即要获取写锁的线程)时,只有获取独占锁线程可以抢占,而试图获取共享锁的线程必须进入队列阻塞;当队列头部结点是共享模式(即要获取读锁的线程)时,试图获取独占和共享锁的线程都可以抢占。
公平锁,利用AQS的等待队列,线程按照FIFO的顺序获取锁,因此不存在写线程一直等待的问题。 - 重入性:读写锁均是可重入的,读/写锁重入次数保存在了32位int state的高/低16位中。而单个读线程的重入次数,则记录在ThreadLocalHoldCounter类型的readHolds里。
- 锁降级:写线程获取写入锁后可以获取读取锁,然后释放写入锁,这样就从写入锁变成了读取锁,从而实现锁降级。
- 锁获取中断:读取锁和写入锁都支持获取锁期间被中断。
- 条件变量:写锁提供了条件变量(Condition)的支持,这个和独占锁ReentrantLock一致,但是读锁却不允许,调用readLock().newCondition()会抛出UnsupportedOperationException异常。
StampedLock戳锁 一个高性能的读写锁
使用:
StampedLock控制锁有三种模式(排它写,悲观读,乐观读),一个StampedLock状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据stamp,它用相应的锁状态表示并控制访问。
原理:
StampedLockd的内部实现是基于CLH(自旋锁队列)锁的,一种自旋锁,保证没有饥饿且FIFO。
CLH锁原理:锁维护着一个等待线程队列,所有申请锁且失败的线程都记录在队列。一个节点代表一个线程,保存着一个标记位locked,用以判断当前线程是否已经释放锁。当一个线程试图获取锁时,从队列尾节点作为前序节点,循环判断所有的前序节点是否已经成功释放锁。
StampedLockd内部维护了一个StampedLockd,源码中的WNote就是等待链表队列,每一个WNode标识一个等待线程,whead为CLH队列头,wtail为CLH队列尾,state为锁的状态。long型即64位,倒数第八位标识写锁状态,如果为1,标识写锁占用!
首先是常量标识:
WBIT=1000 0000(即-128)
RBIT =0111 1111(即127)
SBIT =1000 0000(后7位表示当前正在读取的线程数量,清0)
- 乐观读:
tryOptimisticRead():如果当前没有写锁占用,返回state(后7位清0,即清0读线程数),如果有写锁,返回0,即失败。 - 校验stamp:
校验这个戳是否有效validate():比较当前stamp和发生乐观锁得到的stamp比较,不一致则失败。 - 悲观读:
乐观锁失败后锁升级为readLock():尝试state+1,用于统计读线程的数量,如果失败,进入acquireRead()进行自旋,通过CAS获取锁。如果自旋失败,入CLH队列,然后再自旋,如果成功获得读锁,激活cowait队列中的读线程Unsafe.unpark(),最终依然失败,Unsafe().park()挂起当前线程。 - 排它写:
writeLock():典型的cas操作,如果STATE等于s,设置写锁位为1(s+WBIT)。acquireWrite跟acquireRead逻辑类似,先自旋尝试、加入等待队列、直至最终Unsafe.park()挂起线程。 - 释放锁
unlockWrite():释放锁与加锁动作相反。将写标记位清零,如果state溢出,则退回到初始值;
自旋锁(SPIN LOCK)
自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。
缺点
- CAS操作需要硬件的配合;
- 保证各个CPU的缓存(L1、L2、L3、跨CPU Socket、主存)的数据一致性,通讯开销很大,在多处理器系统上更严重;
- 没法保证公平性,不保证等待进程/线程按照FIFO顺序获得锁。
排队自旋锁(TICKET LOCK)
Ticket Lock 是为了解决上面的公平性问题,类似于现实中银行柜台的排队叫号:锁拥有一个服务号,表示正在服务的线程,还有一个排队号;每个线程尝试获取锁之前先拿一个排队号,然后不断轮询锁的当前服务号是否是自己的排队号,如果是,则表示自己拥有了锁,不是则继续轮询。
缺点
Ticket Lock 虽然解决了公平性的问题,但是多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量serviceNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
MCS锁 && CLH锁
- MCS Spinlock 是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。
- CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
差异:
从代码实现来看,CLH比MCS要简单得多。
从自旋的条件来看,CLH是在前驱节点的属性上自旋,而MCS是在本地属性变量上自旋。
从链表队列来看,CLH的队列是隐式的,CLHNode并不实际持有下一个节点;MCS的队列是物理存在的。
CLH锁释放时只需要改变自己的属性,MCS锁释放则需要改变后继节点的属性。
网友评论