JUC
JAVA创建线程的四种方式
继承Thread类
- 定义thread类的子类,并重写run方法,该方法的方法体就是线程需要完成的任务,run方法也称为线程执行体。
- 创建Thread类的实例,也就是创建了线程对象
- 启动线程,即调用线程的start方法
实现Runnable接口
- 定义Runnable接口的实现类,重写run方法,run方法同样是线程执行体
- 创建实现类的实例,并用这个实例作为Thread类的target来创建Thread对象,这个Thread对象便是线程对象
- 启动线程,调用start方法
使用Callable和future创建
future接口是jdk1.5引入的,可以用来接收callable接口里call方法的返回值
有一个实现类futureTask,实现了future和runnable接口,因此可以作为thread类的target
- 创建callable接口的实现类,并实现call方法,然后创建该实现类的实例
- 使用futureTask类来包装callable对象
- 使用futureTask对象作为thread对象的target创建并启动线程
- 使用futureTask对象的get方法来获取子线程执行结束后的返回值
call方法比run方法更加强大:可以有返回值,可以抛出异常
使用executor框架
JDK1.5引入的executor框架最大的优点就是把任务的提交和执行解耦
开发者只需描述好要只需的任务,然后提交即可
- 创建一个ExecutorService,
ExecutorService executorService = Executors.newFixedThreadPool(5);
- 若有返回值,将写好的runnable实例或者callable实例作为target submit即可,返回值是一个future对象,所以可以使用get方法获取返回值.
- 若无返回值,直接使用execute方法即可
ExecutorService.execute(Runnable command);
executor框架的内部使用了线程池的机制,可以作为一个工厂类来创建线程池
从上图可以看出,应用程序通过Executor框架控制上层的调度;而下层的调度由操作系统内核控制,下层的调度不受应用程序的控制。
线程的生命周期
创建
当使用new关键字创建了一个线程之后,该线程就处于一个新建状态,此时它和其他java对象一样,仅仅被分配了内存,并初始化了成员变量值。没有线程的动态特征,也不会执行线程的执行体
就绪
当调用start方法后,该线程处于就绪状态。JVM会为其创建虚拟机栈和PC,处于这个状态表示线程可以运行了,等待被调度执行
运行
在就绪状态下,若被OS调度,就会进入运行状态。当时间片用完或者调用线程让步时,回到就绪状态
阻塞
- 等待阻塞:线程执行wait方法,JVM会将其放入等待池中,此时线程会释放持有的锁
- 同步阻塞:即被synchronized修饰的代码块被其他线程拿到,本线程获取同步锁失败,就会被JVM放入锁池中
- 其他阻塞:线程执行sleep或者join方法,或者发出了IO请求。当sleep超时,join等待线程终止或者等待超时,IO完毕,线程就会重新转入就绪状态
调度方法 | |
---|---|
sleep | 线程睡眠,使线程转入阻塞状态一定时间 |
wait | 线程等待,使线程放入等待池,直到其他线程调用notify或者notifyall方法来唤醒,此时线程会尝试获取锁,若成功,转为就绪状态,若失败,则进入锁池等待锁的释放。 |
yield | 线程让步,暂停当前正在执行的线程对象,回到就绪状态,把执行机会让给优先级相同或者更高的线程 |
join | 线程加入,等待其他线程终止,在当前进程中调用另一个指定进程的join方法,则当前进程转入阻塞状态,直到另一个进程运行结束,当前进程再由阻塞转为就绪状态 |
notify | 线程唤醒,唤醒被wait阻塞的进程 |
死亡
- run方法执行完成,线程正常结束
- 抛出异常
- 直接调用stop方法来结束(容易造成死锁)
线程池
一种多线程的处理形式,处理过程中可以将任务添加到队列中,然后在创建线程后自动启动这些任务。
线程池的优势
- 线程和任务分离,线程可被重用,提升复用性
- 控制线程并发数量,统一管理,降低服务器压力
- 提升系统响应速度,因为线程池内的线程可以被复用,且线程池内有核心线程待命,所以就减少了创建线程和销毁线程的时间。
为什么要使用线程池
JAVA线程的创建十分昂贵,需要JVM和OS配合完成大量的工作
- 必须为线程堆栈分配和初始化大量的内存块,其中至少包含1MB的栈内存
- JVM的线程模型为1:1模型,即JVM的线程和OS的线程是1:1对应的,需要进行系统调用,以便在OS中创建和注册本地线程
Java的高并发应用频繁创建和销毁线程的操作是十分低效的,且不符合编程规范的,所以需要使用线程池来独立负责线程的创建维护和分配,以提升性能,减少资源消耗。
应用场景:网购商品秒杀,云盘文件上传,旅行系统购票等等
解析
1 |
|
参数解释:
- corePoolSize : 指空闲也不允许被销毁的线程,随时待命存放于线程池中
- maximumPoolSize:指最大线程数,当任务队列满时,需要创建临时进程处理无法进入任务队列的任务。当临时进程空闲时,会被销毁
- keepAliveTime&TimeUnit:最大空闲时间和时间单位,当临时进程空闲时间超过最大空闲时间后,便会被销毁
- BlockingQueue:阻塞队列,当核心线程均不空闲时,任务进入队列等待。队列可以用多种数据结构实现,永远推荐使用有界队列,即由数组实现的队列,并设立合理的长度。避免造成等待任务过多消耗系统资源。
- ThreadFactory :线程工厂,手动命名创建线程的工厂,方便抛出错误后定位相应线程池
- RejectedExecutionHandler:拒绝策略,当任务队列满且所有线程均不空闲时,启用饱和处理机制
线程池的阻塞队列
- ArrayBlockingQueue:底层采用数组实现的有界队列,初始化需要指定队列的容量。ArrayBlockingQueue 是如何保证线程安全的呢?它内部是使用了一个重入锁 ReentrantLock,并搭配 notEmpty、notFull 两个条件变量 Condition 来控制并发访问。从队列读取数据时,如果队列为空,那么会阻塞等待,直到队列有数据了才会被唤醒。如果队列已经满了,也同样会进入阻塞状态,直到队列有空闲才会被唤醒。
- LinkedBlockingQueue:底层采用的数据结构是链表,队列的长度可以是有界或者无界的,初始化不需要指定队列长度,默认是 Integer.MAX_VALUE。LinkedBlockingQueue 内部使用了 takeLock、putLock两个重入锁 ReentrantLock,以及 notEmpty、notFull 两个条件变量 Condition 来控制并发访问。采用**读锁和写锁(锁分离)**的好处是可以避免读写时相互竞争锁的现象,所以相比于 ArrayBlockingQueue,LinkedBlockingQueue 的性能要更好。
- SynchronousQueue:又称无缓冲队列。比较特别的是 SynchronizedQueue 内部不会存储元素。与 ArrayBlockingQueue、LinkedBlockingQueue 不同,SynchronizedQueue 直接使用 CAS 操作控制线程的安全访问。其中 put 和 take 操作都是阻塞的,每一个 put 操作都必须阻塞等待一个 take 操作,反之亦然。所以 SynchronizedQueue 可以理解为生产者和消费者配对的场景,双方必须互相等待,直至配对成功。在 JDK 的线程池 Executors.newCachedThreadPool 中就存在 SynchronousQueue 的运用,对于新提交的任务,如果有空闲线程,将重复利用空闲线程处理任务,否则将新建线程进行处理。
- PriorityBlockingQueue:底层最小堆实现的优先级队列,队列中的元素按照优先级进行排列,每次出队都是返回优先级最高的元素。PriorityBlockingQueue 内部是使用了一个 ReentrantLock 以及一个条件变量 Condition notEmpty 来控制并发访问,不需要 notFull 是因为 PriorityBlockingQueue 是无界队列,所以每次 put 都不会发生阻塞。PriorityBlockingQueue 底层的最小堆是采用数组实现的,当元素个数大于等于最大容量时会触发扩容,在扩容时会先释放锁,保证其他元素可以正常出队,然后使用 CAS 操作确保只有一个线程可以执行扩容逻辑。
如何确定核心线程数,最大线程数,任务队列长度
核心线程数:IO密集型:CPU数*2;CPU密集型:CPU数+1
最大线程数:(每秒产生的最大任务数-任务队列长度)*单个任务执行时间
任务队列长度:核心线程数/单个任务执行时间*2
饱和处理机制有哪些
- AbortPolicy:丢弃任务并抛出异常
- DiscardPolicy:丢弃任务不抛出异常
- DiscardOldestPolicy:丢弃最前面的任务,然后重新提交被拒绝的任务
- CallerRunsPolicy:直接调用线程处理该任务
CAS
CAS全称为compare and swap,即比较和交换
这是JDK提供的原子性操作。语义上是两步操作,但是CPU一条指令即可以完成
汇编指令:lock cmpxchg
原子性保证lock:当执行cmpxchg时,其他CPU不允许打断这个操作,lock是硬件级的实现:锁定北桥信号
1 |
|
如果对象中的变量值为expect,则使用新的值update替换expect
替换成功,返回true;替换失败,即变量值不为expect,返回false;
特点:非阻塞,即允许多个线程对共享资源进行修改,但是同一时刻只有一个线程可以进行写操作,其他线程并不是被阻塞,而是在不停重试拿到锁。
在JAVA中若一个线程没有拿到锁被阻塞,就会造成线程的上下文切换,大量线程的重新调度会造成性能的浪费。
volatile只能保证有序性和可见性,不能保证原子性。CAS就保证了原子性。
CAS和volatile两者可以实现无锁并发
所以自旋锁便是通过CAS来实现的,在获取锁的时候使用while循环不断进行CAS操作,类似于不断旋转,直到操作成功返回true
,在释放锁的时候使用CAS将锁的状态从1变成0。
ABA问题:
假如线程1使用CAS修改初始值为A的变量X=A,那么线程1首先会获取当前变量X的值(A),然后使用CAS操作尝试修改X的值为B,如果使用CAS修改成功了,那么程序运行一定是正常的吗?
有可能在线程1获取到变量X的值A后,在执行CAS之前,线程2使用了CAS修改了变量X值为B,然后又使用了CAS操作使得变量X值为A,虽然线程A执行了CAS操作时X=A,但是这个A已经不是线程1获取到的A了。这就是ABA问题,ABA问题的产生是因为变量的状态值产生了环形转换,就是变量值可以从A到B,也可以B到A,如果变量的值只能朝着一个方向转换,例如A到B,B到C,不构成环路,就不会存在这个问题。
如何解决ABA问题
引入原子类:
AtomicStampedReference 是通过版本号(时间戳)来解决 ABA 问题的,也可以使用版本号(verison)来解决 ABA,即乐观锁每次在执行数据的修改操作时,都带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行 +1 操作,否则执行失败。
AtomicMarkableReference 则是将一个 boolean 值作是否有更改的标记,本质就是它的版本号只有两个,true 和 false,修改的时候在两个版本号之间来回切换,虽然这样做并不能解决 ABA 的问题,但是会降低 ABA 问题发生的几率。
ThreadLocal
即线程本地变量,使公共变量可以在多个线程内进行隔离访问
static ThreadLocal<Object> TL = new ThreadLocal<>();
若线程1对TL设置内容Value1,此时线程2是无法通过get方法拿到Value1的
常用方法及实现原理
set (T value):设置线程本地变量的内容
1 |
|
每一个thread对象里都会自带一个threadLocals对象,而这个对象就是ThreadLocalMap的实例
ThreadLocalMap就是一个存储Entry即键值对的数组,初始化时threadLocals会设置为null
1 |
|
所以set方法并不是往tl对象里面装内容,而是以tl的引用为K,value为V,生成Entry装入该线程的map中
ThreadLocalMap
ThreadLocalMap实现了map接口,但是和hashmap不同,它没有链表或者红黑树,它就是一个散列数组
当发生哈希碰撞的时候,ThreadLocalMap会以线性探测的方式:即指针向后不断移动直到找到null或者相同的key为止,这种方式来存储元素。
ThreadLocalMap的hash值计算:使用斐波那契数的倍数
和(len -1)
按位与:int i = key.threadLocalHashCode & (len - 1);
ThreadLocalMap的扩容:当元素数大于len*2/3时,便会启动扩容,同样是2倍扩容
过期数据的清理:
- 探测式清理:从开始位置向后遍历,清除过期元素,将遍历到的过期数据的
Entry
设置为null
,沿途碰到的未过期的数据则将其rehash
后重新在table
中定位,如果定位到的位置有数据则往后遍历找到第一个Entry=null
的位置存入。接着继续往后检查过期数据,直到遇到空的桶才终止探测。 - 启发式清理:从参数i开始向后遍历lg2n个位置,遍历中遇到位置上
key=null
时,从此处同步调用探测时清理方法。
get():获取线程本地变量的内容
1 |
|
Entry继承了弱引用类,说明这里的每一个Entry都是一个弱引用,弱引用的使用可以避免内存泄漏
ThreadLocal对象的作用:
- 引用作为key来进行查找entry的值
- 维护map,ThreadLocalMap的设置删除都是由ThreadLocal来进行的
在ThreadLocalMap的set/getEntry中,会对key进行判断,如果key为null,那么value也会被设置为null,这样即使在忘记调用了remove方法,当ThreadLocal被销毁时,对应value的内容也会被清空,避免了内存泄漏。
为什么ThreadLocal包装的变量可以实现线程隔离?
thread对象内不方便手动添加成员变量,所以就使用ThreadLocal来实现成员变量的效果。ThreadLocal对象本身不存储值,而是作为一个key来查找不同线程中的map的value,不同线程以ThreadLocal的弱引用作为key的Entry里的Value肯定都是不同的,每一个线程内的map都保存了一份副本各玩儿各的,所以就实现了线程隔离。
ThreadLocal的应用场景
- Spring的@Transaction事务声明的注解中就使用ThreadLocal保存了当前的Connection对象,避免在本次调用的不同方法中使用不同的Connection对象。
- 依赖于ThreadLocal本身的特性,对于需要进行线程隔离的变量可以使用ThreadLocal进行封装
Synchronized
同步锁,保证在同一时刻,被修饰的代码块或方法只有一个线程执行,以达到并发安全的效果
同步锁是解决并发问题最简单的一种方法,直接给代码块加上此关键字即可
在JDK1.5之前,Synchronized是一个重量级锁,在以后的版本经过改进后成重量级减小
synchronized的作用主要有三个:
- 原子性:确保线程互斥地访问同步代码;
- 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的“对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
- 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”;
底层实现:
对象在JVM的内存布局为:对象头+实例数据+对齐填充
对象头(12字节)
其中有4字节的class pointer和8字节的MarkWord
后者是实现锁的关键,MarkWord被设计成一个非固定的数据,它会根据对象的状态复用自身的空间,即会随着程序的运行发生变化。MarkWord的最后三字节分别为:1bit记录是否为偏向锁;2bit记录锁标志位。当锁标志位变为00时为轻量级锁,01代表未锁定或者可加偏向锁,10时为重量级锁。
Monitor对象
如果使用Synchronize修饰了一个对象,则MarkWord就会指向一个唯一的Monitor对象,并将标志位改为10,由操作系统提供
Monitor中有三个变量,分别是Owner、EntryList和WaitSet
Owner:当线程抢占到锁后,Owner就会指向该线程
EntryList:当其他线程以自旋形式抢占Owner超过阈值后,便会进入阻塞状态,放入EntryList,等待被唤醒
具体步骤
- thread0执行synchronize代码的时候,synchronized(obj)的obj对象的markword中ptr_to_heavyweight_monitor(指向monitor的指针)会指向一个monitor对象,执行cas操作将monitor的owner设置为thread0。在字节码中对应monitorenter操作指令
- thread1执行到synchronized代码时,发现obj的markword指向了一个monitor并且owner不为null 并且不为抢锁线程,这时会进入entrylist进行blocked,thread2也一样
- thread0执行完同步代码退出synchronized,把obj markword里的数据还原比如hashcode,这些数据是存在monitor对象中的,然后根据不同的策略去唤醒entrylist的thread1和thread2的blocked线程,两个线程去抢owner。在字节码中对应monitorexit操作指令
偏向锁:
当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
说白了就是消除无竞争情况下的性能消耗,避免一个线程的情况下也去竞争锁,造成浪费资源。
底层实现原理:
- 首先获取锁 对象的 MarkWord,判断是否处于可偏向状态。偏向锁状态位0,锁标志01
- 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID 写入到 MarkWord
a) 如果 CAS 成功,那么 MarkWord就会记录当前线程的ID。 表示已经获得了锁对象的偏向锁,接着执行同步代码块
b) 如果 CAS 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行 - 如果是已偏向状态,需要检查 MarkWord 中存储的
线程ID 是否等于当前线程的 线程ID
a) 如果相等,不需要再次获得锁,可直接执行同步代码块
b) 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁
轻量级锁:
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建 一个LockRecord
然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针(即00),官方称为Displaced Mark Word,谁成功将LockRecord贴上去了,谁就拿到锁了。
如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
轻量级锁不进行阻塞,而是使用自旋的方式,自旋虽然提升了响应速度,但是会增大CPU的消耗
重量级锁:
当竞争加剧,比如自旋次数超过某一阈值,就会升级为重量级锁,JDK1.6之前,需要自己进行调优设置自旋阈值,需要参考CPU核数。而以后的版本加入了自适应自旋,由JVM自动控制。
此时需要向操作系统申请资源,申请mutex,将MarkWord替换为指向mutex的指针,拿到重量级锁
其他线程进入阻塞队列,等待OS的调度,wait状态的线程不消耗cpu
阻塞线程需要cpu从用户态转到内核态,代价比较大。而且可能会出现刚阻塞不久,锁就被释放的情况,所以阻塞的方式会降低响应速度
锁会随着线程的竞争情况逐渐升级,偏向锁 => 轻量级锁 => 重量级锁 。锁可以升级但是不能降级。升级的目的是为了提高获得锁和释放锁的效率。
Volatile
Volatile关键字的作用主要有如下两个:
- 线程的可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
- 顺序一致性:禁止指令重排序
Volatile和synchronized的区别
- Volatile是轻量级的synchronized,因为它不会引起上下文的切换和调度,所以Volatile性能更好。
- Volatile只能修饰变量,synchronized可以修饰方法,静态方法,代码块,类。
- volatile仅能实现变量的修改可见性,并不能保证原子性,synchronized则可以保证原子性。
- 多线程访问volatile不会发生阻塞,而synchronized会发生阻塞。
- volatile是变量在多线程之间的可见性,synchronize是多线程之间访问资源的同步性。
如何保证线程的可见性
JAVA的内存模型
线程之间的共享变量存储在主内存中,而每一个线程都有一个私有的本地内存,local memory存储了该线程读写的共享变量的副本。所以当一个线程在本地内存更新共享变量的副本后,需要重新写入主内存。
如何将新值刷新到主内存中:
CPU寄存器->Cache->Main memory,写缓冲区可以避免处理器停顿下来等待写入数据而造成的延迟,并且写缓冲区可以合并多次写,减少对内存总线的占用。
但是在写入主内存之前,另外一个线程是看不到的,所以就需要volatile关键则来保证可见性
当线程对volatile修饰的变量进行写操作时,汇编指令会多出一个lock前缀,这就是内存屏障,而在多核心环境下,这个前缀会对应两个操作:
- 将当前缓存行的数据立即写回系统内存
- 这个写回内存的操作会使其他cpu里缓存的副本无效化
这就是缓存一致性协议,每个处理器通过嗅探在总线传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,从而需要重新从系统内存中读取数据。
所以多核心环境下,每一个线程读取被volatile修饰的变量时,都必须在主内存中读取最新的结果,而不是使用local memory内的数据,这样保证了一个线程修改变量的结果其他线程都是可知的,保证了线程的可见性。
如何禁止指令重排
同样依赖于lock前缀,即内存屏障实现
编译器不会对volatile读与volatile读后面的任意内存操作重排序;
编译器不会对volatile写与volatile写前面的任意内存操作重排序。
Unsafe 方法
- **putOrderedXxx()**,使用 StoreStore 屏障,会把最新值更新到主内存,但不会立即失效其它缓存行中的数据,是一种延时更新机制;
- **putXxxVolatile()**,使用 StoreLoad 屏障,会把最新值更新到主内存,同时会把其它缓存行的数据失效,或者说会刷新其它缓存行的数据;
- **putXxx(obj, offset)**,不使用任何屏障,更新对象对应偏移量的值;
- **getXxxVolatile()**,使用 LoadLoad 屏障,会从主内存获取最新值;
- **getXxx(obj, offset)**,不使用任何屏障,读取对象对应偏移量的值;
Lock
synchronized存在一些问题:
NonfairSync:加入持有锁的线程因为等待长时间IO或者其他原因,其他等待的线程无法响应中断,只能不断等待
公平锁即尽量以请求锁的顺序来获取锁。比如同时有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该锁。
非公平锁即无法保证锁的获取是按照请求锁的顺序进行的。这样就可能导致某个或者一些线程永远获取不到锁。
synchronize是悲观锁,独占性很强,对读和写操作均是独占的
使用synchronized关键字无法确认线程是否成功获取到锁
异常是否释放
synchronized关键字在发生异常时会自动释放占有的锁,因此不会出现死锁;而lock发生异常的时候,必须手动unlock来释放锁,可能会引起死锁。解决方式:try catch包裹代码块,finally中写入unlock
是否响应中断
lock可以用interrupt来中断等待,而synchronized只能不断等待锁的释放,不能响应中断
是否知道获取锁
lock可以通过trylock来知道有没有获取锁,而synchronized不能
两者的异同
- 在JDK1.5之前lock的性能优于synchronized,以后的版本,在不断优化降低锁的重量级后,两者的性能差距缩小。
- lock是一个接口,而synchronized是一个关键字
- lock可以有多个获取锁的方式,可以不用一直等待。而synchronized只能等待
- Lock适合用于大量线程的同步,且大量线程竞争激烈时,lock的性能更优,lock锁还能使用readwritelock实现读写分离,提高多线程的读操作效率。
- lock可以实现公平锁与非公平锁,synchronized只能实现非公平锁
AQS
即AbstractQueuedSynchronizer类,抽象队列同步器,AQS是JUC的基类
基于 volitile修饰的状态记录量state+Node对象构建的双向链表,先进先出,也就是队列
1 |
|
加锁(非公平为例)
当调用lock()时,线程会尝试使用CAS的方式将state从0改变为1,返回true则证明成功拿到锁,将ExclusiveOwnerThread指向当前线程。若为重入,则会增加state的值。
拿锁失败则会被放入队列。若队列为空,则会建立一个空节点作为哨兵,然后将此节点放在哨兵后。队列中的线程会acquireQueued(),内部由一个死循环实现,自旋地,独占且不可中断的方式获取同步状态,位于第二个节点的线程才有资格抢占锁,抢占后将晋升为头节点,原先的头节点会等待被GC。
若获取锁失败或无资格获取锁,则会则根据前驱节点的waitStatus决定是否需要挂起线程,若为SIGNAL,则当前节点被安全阻塞。
若为CANCELLED,则会向前查找到为SIGNAL的节点,并重新设置前驱节点,相当于是剔除了失效节点。
若为0或者其他状态,通过CAS的方式设置为SIGNAL
锁的释放
release(int arg),先检测state,若state减一后仍不为0,则代表有重入,返回false,等待下一次的释放。
当state为0时,才会进行unpark(),即释放锁
unparkSuccessor(),传入head节点,检测到后继节点中第一个waitStatus为-1的节点,并解除挂起状态
ReentrantLock
1 |
|
互斥锁,可重入锁,也是可以实现公平锁和非公平锁(默认)的一种锁。内部包含一个AQS对象,并基于AQS实现
NonfairSync:非公平锁无论是队列里,还是外来线程,都会通过CAS直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。
fairSync:公平锁则是所有线程并发进入acquire方法,通过hasQueuedPredecessors方法来严格控制队列获取锁的顺序,外来线程无法参与竞争。
ReentrantLock内部有三个类
CountDownLatch
CountDownLatch是一个倒数的计数器阀门,初始化时阀门关闭,指定计数的数量,当数量倒数减到0时阀门打开,被阻塞线程被唤醒
工作方式:初始值为线程数,当线程完成自己的任务后,计数器的值就减一,当计数器为0时,表示所有线程都已完成任务。然后等待的线程就可以恢复执行。
1 |
|
原理:维护一个AQS,将state设置为Count数量,当state为0时,才会唤醒队列中的线程
CyclicBarrier
CyclicBarrier是一个可循环的屏障,它允许多个线程在执行完相应的操作后彼此等待共同到达一个point,等所有线程都到达后再继续执行。比如等所有运动员都跨过第一个栅栏后,才允许继续向前。
工作方式:初始值同样为线程数,当线程完成自己的任务后,计数器的值减一,若state不为0,则自身阻塞,直到state为0,即所有线程都完成任务后,才会从障碍点继续运行。
CyclicBarrier是可以循环的,每个线程可以调用两次的await()方法,重复利用栅栏的计数器。调用nextGeneration()方法,唤醒所有阻塞线程,并重置count。
原理:维护ReentryLock的Lock方法和Condition实现
而计数器阀门则不可以循环,count为0后就不能再使用。
CyclicBarrier和CountDownLatch区别
- CountDownLatch的await()线程会等待计数器减为0,而执行CyclicBarrier的await()方法会使线程进入阻塞等待其他线程到达障点
- CountDownLatch计数器不能重置,CyclicBarrier可以重置循环利用,可以应对更多的情况,比如程序出错后重置
- CountDownLatch是基于AQS的共享模式实现的,CyclicBarrier是基于ReentrantLock和Condition实现的
- CountDownLatch会阻塞主线程,CyclicBarrier不会阻塞主线程,只会阻塞子线程
原子类Atomic
基本类型
- AtomicInteger:线程安全的整型
- AtomicBoolean:线程安全的布尔类型
- AtomicLong:线程安全的长整型
1 |
|
1 |
|
累加器
- LongAdder(性能优)
- LongAccumulator
- DoubleAdder
- DoubleAccumulator
LongAdder
在多线程累加的情况下LongAdder拥有比synchronized,AtomicInteger,AtomicLong,LongAccumulator更高的性能
synchronized肯定是最慢的
LongAdder在无竞争的情况,跟AtomicLong一样,对同一个base进行操作
当出现竞争关系时则是采用一个数组cells,将一个value拆分进这个数组Cells
多个线程需要同时对value进行操作时,可以对线程id进行hash得到hash值,再根据hash值映射到这个数组cells的某个下标,再对该下标所对应的值进行自增操作。当所有线程操作完毕,将数组cells的所有值和无竞争值base都加起来作为最终结果。
总结:
为什么这么快
LongAdder的基本思路就是分散热点,将value值分散到一个Cell数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。
无并发的时候,单线程下直接CAS操作更新base值。有并发的时候,多线程下分段CAS操作更新Cell数组值
如果要获取真正的long值,只要将各个槽中的变量值累加返回。sum()会将所有Cell数组中的value和base累加作为返回值
核心的思想就是将之前AtomicLong一个value的更新压力分散到多个value中去,从而降级更新热点。
与AtomicLong的区别
原理不同:
AtomicLong是以CAS的自旋方式来进行加减
LongAdder则是以CAS+Base+Cell数组分散热点,通过空间换时间分散了热点数据
场景不同:
AtomicLong适用于低并发下的全局计算,能保证并发情况下计数的准确性,可允许一些性能损耗,要求高精度时可使用
LongAdder适用于高并发下的全局计算,当需要在高并发下有较好的性能表现,且对值的精确度要求不高时,可以使用
各有缺点:
AtomicLong 高并发后性能急剧下降
LongAdder 求和后还有计算线程修改结果的话,最后结果不够准确
锁的分类
按抽象概念分
- 悲观锁:悲观地认为数据大概率会被其他线程操作,所以具有强烈的独占性和排它性,比如synchronized,先加锁再执行代码块
- 乐观锁:相反,乐观地认为数据不大会被其他线程操作,所以先执行代码块,遇见线程冲突的情况,再补偿
- 自旋锁:自旋锁是乐观锁的一种实现形式,首先需要了解一些概念
按读写属性分
- 排他锁:又称写锁,X锁,只有一个线程能访问代码块,synchronized关键字即是排他锁。写的时候,不允许其他线程读,也不允许其他线程写
- 共享锁:又称读锁,S锁,可以有多个线程访问代码块,允许同时读,不允许写,必须等所有锁释放后才可以写
- 读写锁:概念同上
按粒度分
- 统一锁:大粒度的锁,防止出现死锁。锁定A线程,等待B线程;锁定B,等待A;若没有很好地同步,就会出现死锁统一锁便是将A和B统一为一个大锁
- 分段锁:JDK1.7 ConcurrentHashMap,如果像HashTable那样锁住整张表,性能会很差,使用分段思想,只对某个segment进行锁定,当锁定一段时,不影响其他段的数据插入,提高了效率,缺点,代码实现复杂。