并发编程
# 你知道Java中有哪些锁
# 题目描述
你知道Java中有哪些锁
# 面试题分析
将各种锁的概念及其区别说出来
# 各种锁
公平锁/非公平锁
可重入锁
独享锁/共享锁
互斥锁/读写锁
乐观锁/悲观锁
分段锁
偏向锁/轻量级锁/重量级锁
自旋锁
上面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词进行一定的解释。
# 公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
对于Java ReentrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于 Synchronized 而言,也是一种非公平锁。由于其并不像 ReentrantLock 是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
# 可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象,下面会有一个代码的示例。
对于Java ReentrantLock 而言, 他的名字就可以看出是一个可重入锁,其名字是 Re entrant Lock 重新进入锁。
对于 Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。
synchronized void setA() throws Exception {
Thread.sleep(1000);
setB();
}
synchronized void setB() throws Exception {
Thread.sleep(1000);
}
上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。
# 独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有。
共享锁是指该锁可被多个线程所持有。
对于Java ReentrantLock 而言,其是独享锁。但是对于Lock的另一个实现类 ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
对于 Synchronized 而言,当然是独享锁。
# 互斥锁/读写锁
上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
互斥锁在Java中的具体实现就是 ReentrantLock
读写锁在Java中的具体实现就是 ReadWriteLock
# 乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
悲观锁在Java中的使用,就是利用各种锁。
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
# 分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap 而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
我们以 ConcurrentHashMap 来说一下分段锁的含义以及设计思想, ConcurrentHashMap 中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
# 偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,并且是针对 Synchronized 。在Java 5通过引入锁升级的机制来实现高效Synchronized 。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
# 自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
# 4种常用Java线程锁的特点,性能比较、使用 场景
# 题⽬描述
4中常⽤Java线程锁的特点,性能⽐较及使⽤场景
# ⾯试题分析
根据题⽬要求我们可以知道:
多线程的缘由
多线程并发⾯临的问题
4种Java线程锁(线程同步)
Java线程锁总结
分析需要全⾯并且有深度
容易被忽略的坑
分析⽚⾯
没有深⼊
# 多线程的缘由
在出现了进程之后,操作系统的性能得到了⼤⼤的提升。虽然进程的出现解决了操作系统的并发问题,但是⼈们仍然不满⾜,⼈们逐渐对实时性有了要求。
使⽤多线程的理由之⼀是和进程相⽐,它是⼀种⾮常花销⼩,切换快,更”节俭”的多任务操作⽅式。
在Linux系统下,启动⼀个新的进程必须分配给它独⽴的地址空间,建⽴众多的数据表来维护它的代码段、堆栈段和数据段,这是⼀种”昂贵”的多任务⼯作⽅式。⽽在进程中的同时运⾏多个线程,它们彼此之间使⽤相同的地址空间,共享⼤部分数据,启动⼀个线程所花费的空间远远⼩于启动⼀个进程所花费的空间,⽽且,线程间彼此切换所需的时间也远远⼩于进程间切换所需要的时间。
# 多线程并发⾯临的问题
由于多个线程是共同占有所属进程的资源和地址空间的,那么就会存在⼀个问题:
如果多个线程要同时访问某个资源,怎么处理?
在Java并发编程中,经常遇到多个线程访问同⼀个 共享资源 ,这时候作为开发者必须考虑如何维护数据⼀致性,这就是Java锁机制(同步问题)的来源。
Java提供了多种多线程锁机制的实现⽅式,常⻅的有:
- synchronized
- ReentrantLock
- Semaphore
- AtomicInteger等
每种机制都有优缺点与各⾃的适⽤场景,必须熟练掌握他们的特点才能在Java多线程应⽤开发时得⼼应⼿。
# 4种Java线程锁(线程同步)
# 1.synchronized
在Java中synchronized关键字被常⽤于维护数据⼀致性。
synchronized机制是给共享资源上锁,只有拿到锁的线程才可以访问共享资源,这样就可以强制使得对共享资源的访问都是顺序的。
Java开发⼈员都认识synchronized,使⽤它来实现多线程的同步操作是⾮常简单的,只要在需要同步的对⽅的⽅法、类或代码块中加⼊该关键字,它能够保证在同⼀个时刻最多只有⼀个线程执⾏同⼀个对象的同步代码,可保证修饰的代码在执⾏过程中不会被其他线程⼲扰。使⽤synchronized修饰的代码具有原⼦性和可⻅性,在需要进程同步的程序中使⽤的频率⾮常⾼,可以满⾜⼀般的进程同步要求。
synchronized (obj) {
//⽅法
…….
}
synchronized实现的机理依赖于软件层⾯上的JVM,因此其性能会随着Java版本的不断升级⽽提⾼。
到了Java1.6,synchronized进⾏了很多的优化,有适应⾃旋、锁消除、锁粗化、轻量级锁及偏向锁等,
效率有了本质上的提⾼。在之后推出的Java1.7与1.8中,均对该关键字的实现机理做了优化。
需要说明的是,当线程通过synchronized等待锁时是不能被Thread.interrupt()中断的,因此程序设计时必须检查确保合理,否则可能会造成线程死锁的尴尬境地。
最后,尽管Java实现的锁机制有很多种,并且有些锁机制性能也⽐synchronized⾼,但还是强烈推荐在多线程应⽤程序中使⽤该关键字,因为实现⽅便,后续⼯作由JVM来完成,可靠性⾼。只有在确定锁机制是当前多线程程序的性能瓶颈时,才考虑使⽤其他机制,如ReentrantLock等。
# 2.ReentrantLock
可重⼊锁,顾名思义,这个锁可以被线程多次重复进⼊进⾏获取操作。
ReentantLock继承接⼝Lock并实现了接⼝中定义的⽅法,除了能完成synchronized所能完成的所有⼯作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的⽅法。
Lock实现的机理依赖于特殊的CPU指定,可以认为不受JVM的约束,并可以通过其他语⾔平台来完成底层的实现。在并发量较⼩的多线程应⽤程序中,ReentrantLock与synchronized性能相差⽆⼏,但在⾼并发量的条件下,synchronized性能会迅速下降⼏⼗倍,⽽ReentrantLock的性能却能依然维持⼀个⽔准。
因此我们建议在⾼并发量情况下使⽤ReentrantLock。
ReentrantLock引⼊两个概念:公平锁与⾮公平锁。
公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁。反之,JVM按随机、就近原则分配锁的机制则称为不公平锁。
ReentrantLock在构造函数中提供了是否公平锁的初始化⽅式,默认为⾮公平锁。这是因为,⾮公平锁实际执⾏的效率要远远超出公平锁,除⾮程序有特殊需要,否则最常⽤⾮公平锁的分配机制。
ReentrantLock通过⽅法lock()与unlock()来进⾏加锁与解锁操作,与synchronized会被JVM⾃动解锁机制不同,ReentrantLock加锁后需要⼿动进⾏解锁。为了避免程序出现异常⽽⽆法正常解锁的情况,使⽤ReentrantLock必须在finally控制块中进⾏解锁操作。通常使⽤⽅式如下所示:
Lock lock = new ReentrantLock();
try {
lock.lock();
//…进⾏任务操作5
}
finally {
lock.unlock();
}
# 3.Semaphore
上述两种锁机制类型都是“互斥锁”,学过操作系统的都知道,互斥是进程同步关系的⼀种特殊情况,相当于只存在⼀个临界资源,因此同时最多只能给⼀个线程提供服务。但是,在实际复杂的多线程应⽤程序中,可能存在多个临界资源,这时候我们可以借助Semaphore信号量来完成多个临界资源的访问。
Semaphore基本能完成ReentrantLock的所有⼯作,使⽤⽅法也与之类似,通过acquire()与release()⽅法来获得和释放临界资源。
经实测,Semaphone.acquire()⽅法默认为可响应中断锁,与ReentrantLock.lockInterruptibly()作⽤效果⼀致,也就是说在等待临界资源的过程中可以被Thread.interrupt()⽅法中断。
此外,Semaphore也实现了可轮询的锁请求与定时锁的功能,除了⽅法名tryAcquire与tryLock不同,其使⽤⽅法与ReentrantLock⼏乎⼀致。Semaphore也提供了公平与⾮公平锁的机制,也可在构造函数中进⾏设定。
Semaphore的锁释放操作也由⼿动进⾏,因此与ReentrantLock⼀样,为避免线程因抛出异常⽽⽆法正常释放锁的情况发⽣,释放锁的操作也必须在finally代码块中完成。
# 4.AtomicInteger
⾸先说明,此处AtomicInteger是⼀系列相同类的代表之⼀,常⻅的还有AtomicLong、AtomicLong等,他们的实现原理相同,区别在与运算对象类型的不同。
我们知道,在多线程程序中,诸如++i或i++等运算不具有原⼦性,是不安全的线程操作之⼀。通常我们会使⽤synchronized将该操作变成⼀个原⼦操作,但JVM为此类操作特意提供了⼀些同步类,使得使⽤更⽅便,且使程序运⾏效率变得更⾼。通过相关资料显示,通常AtomicInteger的性能是ReentantLock的好⼏倍。
# 5. Java线程锁总结
1.synchronized:
在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized是很合适的。原因在于,编译程序通常会尽可能的进⾏优化synchronize,另外可读性⾮常好。
2.ReentrantLock:
在资源竞争不激烈的情形下,性能稍微⽐synchronized差点点。但是当同步⾮常激烈的时候,synchronized的性能⼀下⼦能下降好⼏⼗倍,⽽ReentrantLock确还能维持常态。
⾼并发量情况下使⽤ReentrantLock。
3.Atomic:
和上⾯的类似,不激烈情况下,性能⽐synchronized略逊,⽽激烈的时候,也能维持常态。激烈的时候,Atomic的性能会优于ReentrantLock⼀倍左右。但是其有⼀个缺点,就是只能同步⼀个值,⼀段代码中只能出现⼀个Atomic的变量,多于⼀个同步⽆效。因为他不能在多个Atomic之间同步。
所以,我们写同步的时候,优先考虑synchronized,如果有特殊需要,再进⼀步优化。ReentrantLock和Atomic如果⽤的不好,不仅不能提⾼性能,还可能带来灾难。
以上就是Java线程锁的详解,除了从编程的⻆度应对⾼并发,更多还需要从架构设计的层⾯来应对⾼并发场景,例如:Redis缓存、CDN、异步消息等,详细的内容如下。
# 6.扩展内容
AQS的实现原理
线程池的实现原理、优点与⻛险、以及四种线程池实现
CountDownLatch、Semaphore等4⼤并发⼯具类详解
# 并发容器的原理
# 题⽬描述
并发容器的原理
# ⾯试题分析
根据题⽬要求我们可以知道:
并发容器的由来
什么是并发容器
并发容器有哪些分类
ConcurrentHashMap的实现
分析需要全⾯并且有深度
容易被忽略的坑
分析⽚⾯
没有深⼊
# 并发容器的由来
在Java并发编程中,经常听到Java集合类,同步容器、并发容器,那么他们有哪些具体分类,以及各⾃之间的区别和优劣呢?
只有把这些梳理清楚了,你才能真正掌握在⾼并发的环境下,正确使⽤好并发容器,我们先从Java集合类,同步容器谈起。
# 1.什么是同步容器
Java的集合容器框架中,主要有四⼤类别:List、Set、Queue、Map,⼤家熟知的这些集合类ArrayList、LinkedList、HashMap这些容器都是⾮线程安全的。
如果有多个线程并发地访问这些容器时,就会出现问题。因此,在编写程序时,在多线程环境下必须要求程序员⼿动地在任何访问到这些容器的地⽅进⾏同步处理,这样导致在使⽤这些容器的时候⾮常地不⽅便。
所以,Java先提供了同步容器供⽤户使⽤。
同步容器可以简单地理解为通过synchronized来实现同步的容器,⽐如Vector、Hashtable以及SynchronizedList等容器。
# 2.同步容器,主要的分类:
Vector
Stack
HashTable
Collections.synchronized⽅法⽣成
同步容器⾯临的问题
可以通过查看Vector,Hashtable等这些同步容器的实现代码,可以看到这些容器实现线程安全的⽅式就是将它们的状态封装起来,并在需要同步的⽅法上加上关键字synchronized。
这样做的代价是削弱了并发性,当多个线程共同竞争容器级的锁时,吞吐量就会降低。
例如: HashTable只要有⼀条线程获取了容器的锁之后,其他所有的线程访问同步函数都会被阻塞,因此同⼀时刻只能有⼀条线程访问同步函数。
因此为了解决同步容器的性能问题,所以才有了并发容器。
# 什么是并发容器
java.util.concurrent包中提供了多种并发类容器。
并发类容器是专⻔针对多线程并发设计的,使⽤了锁分段技术,只对操作的位置进⾏同步操作,但是其他没有操作的位置其他线程仍然可以访问,提⾼了程序的吞吐量。
采⽤了CAS算法和部分代码使⽤synchronized锁保证线程安全。
# 并发容器有哪些分类
# 1.ConcurrentHashMap
对应的⾮并发容器:HashMap
⽬标:代替Hashtable、synchronizedMap,⽀持复合操作
原理:JDK6中采⽤⼀种更加细粒度的加锁机制Segment“分段锁”,JDK8中采⽤CAS⽆锁算法。
# 2.CopyOnWriteArrayList
对应的⾮并发容器:ArrayList
⽬标:代替Vector、synchronizedList
原理:利⽤⾼并发往往是读多写少的特性,对读操作不加锁,对写操作,先复制⼀份新的集合,在新的集合上⾯修改,然后将新集合赋值给旧的引⽤,并通过volatile 保证其可⻅性,当然写操作的锁是必不可少的了。
# 3.CopyOnWriteArraySet
对应的⾮并发容器:HashSet
⽬标:代替synchronizedSet
原理:基于CopyOnWriteArrayList实现,其唯⼀的不同是在add时调⽤的是CopyOnWriteArrayList的addIfAbsent⽅法,其遍历当前Object数组,如Object数组中已有了当前元素,则直接返回,如果没有则放⼊Object数组的尾部,并返回。
# 4.ConcurrentSkipListMap
对应的⾮并发容器:TreeMap
⽬标:代替synchronizedSortedMap(TreeMap)
原理:Skip list(跳表)是⼀种可以代替平衡树的数据结构,默认是按照Key值升序的。
# 5.ConcurrentSkipListSet
对应的⾮并发容器:TreeSet
⽬标:代替synchronizedSortedSet
原理:内部基于ConcurrentSkipListMap实现
# 6.ConcurrentLinkedQueue
不会阻塞的队列
对应的⾮并发容器:Queue
原理:基于链表实现的FIFO队列(LinkedList的并发版本)
# 7.LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue
对应的⾮并发容器:BlockingQueue
特点:拓展了Queue,增加了可阻塞的插⼊和获取等操作
原理:通过ReentrantLock实现线程安全,通过Condition实现阻塞和唤醒
实现类:
LinkedBlockingQueue:基于链表实现的可阻塞的FIFO队列
ArrayBlockingQueue:基于数组实现的可阻塞的FIFO队列
PriorityBlockingQueue:按优先级排序的队列
# ConcurrentHashMap的实现
HashMap,Hashtable与ConcurrentHashMap都是实现的哈希表数据结构,在随机读取的时候效率很⾼。
Hashtable实现同步是利⽤synchronized关键字进⾏锁定的,其是针对整张哈希表进⾏锁定的,即每次锁住整张表让线程独占,在线程安全的背后是巨⼤的浪费。
ConcurrentHashMap和Hashtable主要区别就是围绕着锁的粒度进⾏区别以及如何区锁定。
上图中,左边是Hashtable的实现⽅式,可以看到锁住整个哈希表;⽽右边则是ConcurrentHashMap的实现⽅式,单独锁住每⼀个桶(segment).ConcurrentHashMap将哈希表分为16个桶(默认值),诸如get(),put(),remove()等常⽤操作只锁当前需要⽤到的桶,⽽size()才锁定整张表。
原来只能⼀个线程进⼊,现在却能同时接受16个写线程并发进⼊(写线程需要锁定,⽽读线程⼏乎不受限制)。
所以,才有了并发性的极⼤提升。
# 扩展内容
并发⼯具类都有哪些?
ConcurrentHashMap底层实现原理?
# 你了解Java并发之AQS
# 题目描述
你了解Java并发之AQS?
# 题目解决
# 1 AQS是什么?有什么用?
AQS全称 AbstractQueuedSynchronizer ,即抽象的队列同步器,是一种用来构建锁和同步器的框架。
基于AQS构建同步器:
ReentrantLock
Semaphore
CountDownLatch
ReentrantReadWriteLock
SynchronusQueue
FutureTask
优势:
AQS 解决了在实现同步器时涉及的大量细节问题,例如自定义标准同步状态、FIFO 同步队列。
基于 AQS 来构建同步器可以带来很多好处。它不仅能够极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题。
# 2 AQS核心知识
# 2.1 AQS核心思想
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。如图所示:
Sync queue: 同步队列,是一个双向列表。包括head节点和tail节点。head节点主要用作后续的调度。
Condition queue: 非必须,单向列表。当程序中存在cindition的时候才会存在此列表。
# 2.2 AQS设计思想
AQS使用一个int成员变量来表示同步状态
使用Node实现FIFO队列,可以用于构建锁或者其他同步装置
AQS资源共享方式:独占Exclusive(排它锁模式)和共享Share(共享锁模式)
AQS它的所有子类中,要么实现并使用了它的独占功能的api,要么使用了共享锁的功能,而不会同时使用两套api,即便是最有名的子类ReentrantReadWriteLock也是通过两个内部类读锁和写锁分别实现了两套api来实现的
# 2.3 state状态
state状态使用volatile int类型的变量,表示当前同步状态。
state的访问方式有三种:
getState()
setState()
compareAndSetState()
# 2.4 AQS中Node常量含义
CANCELLEDwaitStatus值为1时表示该线程节点已释放(超时、中断),已取消的节点不会再阻塞。
SIGNALwaitStatus为-1时表示该线程的后续线程需要阻塞,即只要前置节点释放锁,就会通知标识为 SIGNAL 状态的后续节点的线程
CONDITIONwaitStatus为-2时,表示该线程在condition队列中阻塞(Condition有使用)
PROPAGATEwaitStatus为-3时,表示该线程以及后续线程进行无条件传播(CountDownLatch中有使用)共享模式下, PROPAGATE 状态的线程处于可运行状态
# 2.5 同步队列为什么称为FIFO呢?
因为只有前驱节点是head节点的节点才能被首先唤醒去进行同步状态的获取。当该节点获取到同步状态时,它会清除自己的值,将自己作为head节点,以便唤醒下一个节点。
# 2.6 Condition队列
除了同步队列之外,AQS中还存在Condition队列,这是一个单向队列。调用ConditionObject.await()方法,能够将当前线程封装成Node加入到Condition队列的末尾,然后将获取的同步状态释放(即修改同步状态的值,唤醒在同步队列中的线程)。
Condition队列也是FIFO。调用ConditionObject.signal()方法,能够唤醒firstWaiter节点,将其添加到同步队列末尾。
# 2.7 自定义同步器的实现
在构建自定义同步器时,只需要依赖AQS底层再实现共享资源state的获取与释放操作即可。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
# 3 AQS实现细节
线程首先尝试获取锁,如果失败就将当前线程及等待状态等信息包装成一个node节点加入到FIFO队列中。 接着会不断的循环尝试获取锁,条件是当前节点为head的直接后继才会尝试。如果失败就会阻塞自己直到自己被唤醒。而当持有锁的线程释放锁的时候,会唤醒队列中的后继线程。
# 3.1 独占模式下的AQS
所谓独占模式,即只允许一个线程获取同步状态,当这个线程还没有释放同步状态时,其他线程是获取不了的,只能加入到同步队列,进行等待。
很明显,我们可以将state的初始值设为0,表示空闲。当一个线程获取到同步状态时,利用CAS操作让state加1,表示非空闲,那么其他线程就只能等待了。释放同步状态时,不需要CAS操作,因为独占模式下只有一个线程能获取到同步状态。ReentrantLock、CyclicBarrier正是基于此设计的。
例如,ReentrantLock,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。
独占模式下的AQS是不响应中断的,指的是加入到同步队列中的线程,如果因为中断而被唤醒的话,不会立即返回,并且抛出InterruptedException。而是再次去判断其前驱节点是否为head节点,决定是否争抢同步状态。如果其前驱节点不是head节点或者争抢同步状态失败,那么再次挂起。
# 3.1.1 独占模式获取资源-acquire方法
acquire以独占exclusive方式获取资源。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。源码如下:
public final void acquire(int arg) {
if(!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();
}
流程图:
调用自定义同步器的 tryAcquire() 尝试直接去获取资源,如果成功则直接返回;
没成功,则 addWaiter() 将该线程加入等待队列的尾部,并标记为独占模式;
acquireQueued() 使线程在等待队列中休息,有机会时(轮到自己,会被 unpark() )会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt() ,将中断补上。
# 3.1.2 独占模式获取资源-tryAcquire方法
tryAcquire 尝试以独占的方式获取资源,如果获取成功,则直接返回 true ,否则直接返回 false ,且具体实现由自定义AQS的同步器实现的。
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
# 3.1.3 独占模式获取资源-addWaiter方法
根据不同模式( Node.EXCLUSIVE 互斥模式、 Node.SHARED 共享模式)创建结点并以CAS的方式将当前线程节点加入到不为空的等待队列的末尾(通过 compareAndSetTail() 方法)。如果队列为空,通过enq(node) 方法初始化一个等待队列,并返回当前节点。
/** * 参数 * @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
\* 返回值 * @return the new node */
private Node addWaiter(Node mode) {
//将当前线程以指定的模式创建节点
node Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure // 获取当前同队列的尾节点
Node pred = tail;
//队列不为空,将新的node加入等待队列中
if(pred != null) {
node.prev = pred; //CAS方式将当前节点尾插入队列中
if(compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//当队列为empty或者CAS失败时会调用enq方法处理 enq(node);
return node;
}
其中,队列为empty,使用 enq(node) 处理,将当前节点插入等待队列,如果队列为空,则初始化当前队列。所有操作都是CAS自旋的方式进行,直到成功加入队尾为止。
private Node enq(final Node node) {
//不断自旋
for(;;) {
Node t = tail; //当前队列为empty
if(t == null) { // Must
initialize //完成队列初始化操作,头结点中不放数据,只是作为起始标记,lazy-load, 在第一次用的时候new
if(compareAndSetHead(new Node())) tail = head;
} else {
node.prev = t;
//不断将当前节点使用CAS尾插入队列中直到成功为止
if(compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
# 3.1.4 独占模式获取资源-acquireQueued方法
acquireQueued 用于已在队列中的线程以独占且不间断模式获取state状态,直到获取锁后返回。主要流程:
结点node进入队列尾部后,检查状态;
调用park()进入waiting状态,等待unpark()或interrupt()唤醒;
被唤醒后,是否获取到锁。如果获取到,head指向当前结点,并返回从入队到获取锁的整个过程中是否被中断过;如果没获取到,继续流程1
final boolean acquireQueued(final Node node, int arg) {
//是否已获取锁的标志,默认为true 即为尚未
boolean failed = true;
try {
//等待中是否被中断过的标记
boolean interrupted = false;
for(;;) {
//获取前节点
final Node p = node.predecessor();
//如果当前节点已经成为头结点, 尝试获取锁( tryAcquire) 成功, 然后返回
if(p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
//shouldParkAfterFailedAcquire根据对当前节点的前一个节点的状态进行判断, 对当前节点做出不同的操作
//parkAndCheckInterrupt让线程进入等待状态,并检查当前线程是否被可以被中断
if(shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true;
}
} finally {
//将当前节点设置为取消状态;取消状态设置为1
if (failed) cancelAcquire(node);
}
}
# 3.1.5 独占模式释放资源-release方法
release方法是独占exclusive模式下线程释放共享资源的锁。它会调用tryRelease()释放同步资源,如果全部释放了同步状态为空闲(即state=0),当同步状态为空闲时,它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock().
public final boolean release(int arg) {
if(tryRelease(arg)) {
Node h = head;
if(h != null && h.waitStatus != 0) unparkSuccessor(h);
return true;
}
return false;
}
# 3.1.6 独占模式释放资源-tryRelease方法
tryRelease() 跟 tryAcquire() 一样实现都是由自定义定时器以独占exclusive模式实现的。因为其是独占模式,不需要考虑线程安全的问题去释放共享资源,直接减掉相应量的资源即可(state-=arg)。而且 tryRelease() 的返回值代表着该线程是否已经完成资源的释放,因此在自定义同步器的tryRelease() 时,需要明确这条件,当已经彻底释放资源(state=0),要返回true,否则返回false。
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
ReentrantReadWriteLock的实现:
protected final boolean tryRelease(int releases) {
if(!isHeldExclusively()) throw new IllegalMonitorStateException();
//减掉相应量的资源(state-=arg)
int nextc = getState() - releases;
//是否完全释放资源
boolean free = exclusiveCount(nextc) == 0;
if(free) setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
# 3.1.7 独占模式释放资源-unparkSuccessor方法
unparkSuccessor 用unpark()唤醒等待队列中最前驱的那个未放弃线程,此线程并不一定是当前节点的next节点,而是下一个可以用来唤醒的线程,如果这个节点存在,调用unpark()方法唤醒。
private void unparkSuccessor(Node node) {
// 当前线程所在的结点node
int ws = node.waitStatus;
//置零当前线程所在的结点状态,允许失败
if(ws < 0) compareAndSetWaitStatus(node, ws, 0);
//找到下一个需要唤醒的结点
Node s = node.next;
if(s == null || s.waitStatus > 0) {
s = null;
// 从后向前找
for(Node t = tail; t != null && t != node; t = t.prev)
//从这里可以看出,<=0的结点,都是还有效的结点
if(t.waitStatus <= 0) s = t;
}
if(s != null)
//唤醒
LockSupport.unpark(s.thread);
}
# 3.2 共享模式下的AQS
共享模式,当然是允许多个线程同时获取到同步状态,共享模式下的AQS也是不响应中断的.
很明显,我们可以将state的初始值设为N(N > 0),表示空闲。每当一个线程获取到同步状态时,就利用CAS操作让state减1,直到减到0表示非空闲,其他线程就只能加入到同步队列,进行等待。释放同步状态时,需要CAS操作,因为共享模式下,有多个线程能获取到同步状态。
CountDownLatch、Semaphore正是基于此设计的。
例如,CountDownLatch,任务分为N个子线程去执行,同步状态state也初始化为N(注意N要与线程个数一致):
# 3.2.1 共享模式获取资源-acquireShared方法
acquireShared 在共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。
public final void acquireShared(int arg) {
if(tryAcquireShared(arg) < 0) doAcquireShared(arg);
}
流程:
先通过tryAcquireShared()尝试获取资源,成功则直接返回;
失败则通过doAcquireShared()中的park()进入等待队列,直到被unpark()/interrupt()并成功获取
到资源才返回(整个等待过程也是忽略中断响应)。
# 3.2.2 共享模式获取资源-tryAcquireShared方法
tryAcquireShared() 跟独占模式获取资源方法一样实现都是由自定义同步器去实现。但AQS规范中已定义好 tryAcquireShared() 的返回值:
负值代表获取失败;
0代表获取成功,但没有剩余资源;
正数表示获取成功,还有剩余资源,其他线程还可以去获取。
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
# 3.2.3 共享模式获取资源-doAcquireShared方法
doAcquireShared() 用于将当前线程加入等待队列尾部休息,直到其他线程释放资源唤醒自己,自己成功拿到相应量的资源后才返回。
private void doAcquireShared(int arg) {
//加入队列尾部
final Node node = addWaiter(Node.SHARED);
//是否成功标志
boolean failed = true;
try {
//等待过程中是否被中断过的标志
boolean interrupted = false;
for(;;) {
final Node p = node.predecessor(); //获取前驱节点
if(p == head) { //如果到head的下一个,因为head是拿到资源的线程, 此时node被唤醒, 很可能是head用完资源来唤醒自己的
int r = tryAcquireShared(arg); //尝试获取资源
if(r >= 0) { //成功
setHeadAndPropagate(node, r); //将head指向自己,还有剩余资源可以再唤醒之后
的线程 p.next = null; // help GC
if(interrupted) //如果等待过程中被打断过,此时将中断补上。
selfInterrupt();
failed = false;
return;
}
}
//判断状态,队列寻找一个适合位置, 进入waiting状态, 等着被unpark() 或interrupt()
if(shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true;
}
} finally {
if(failed) cancelAcquire(node);
}
}
# 3.2.4 共享模式释放资源-releaseShared方法
releaseShared() 用于共享模式下线程释放共享资源,释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。
public final boolean releaseShared(int arg) {
//尝试释放资源
if(tryReleaseShared(arg)) {
//唤醒后继结点
doReleaseShared();
return true;
}
return false;
}
独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。 https://www.cnblogs.com/waterystone/p/4920797.html
# 3.2.5 共享模式释放资源-doReleaseShared方法
doReleaseShared() 主要用于唤醒后继节点线程,当state为正数,去获取剩余共享资源;当state=0时去获取共享资源。
private void doReleaseShared() {
for(;;) {
Node h = head;
if(h != null && h != tail) {
int ws = h.waitStatus;
if(ws == Node.SIGNAL) {
if(!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; //唤醒后继
unparkSuccessor(h);
} else if(ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue;
}
// head发生变化
if(h == head) break;
}
}
# 你知道ReentrantLock吗,谈一谈对它的理解
# 题目描述
深入理解ReentrantLock原理
# 题目解决
# ReentrantLock是什么?
ReentrantLock是个典型的独占模式AQS,同步状态为0时表示空闲。当有线程获取到空闲的同步状态时,它会将同步状态加1,将同步状态改为非空闲,于是其他线程挂起等待。在修改同步状态的同时,并记录下自己的线程,作为后续重入的依据,即一个线程持有某个对象的锁时,再次去获取这个对象的锁是可以成功的。如果是不可重入的锁的话,就会造成死锁。
ReentrantLock会涉及到公平锁和非公平锁,实现关键在于成员变量 sync 的实现不同,这是锁实现互斥同步的核心。
//公平锁和非公平锁的变量
private final Sync sync;
//父类
abstract static class Sync extends AbstractQueuedSynchronizer {}
//公平锁子类
static final class FairSync extends Sync {}
//非公平锁子类
static final class NonfairSync extends Sync {}
那公平锁和非公平锁是什么?有什么区别?
# 那公平锁和非公平锁是什么?有什么区别?
公平锁是指当锁可用时,在锁上等待时间最长的线程将获得锁的使用权,即先进先出。而非公平锁则随机分配这种使用权,是一种抢占机制,是随机获得锁,并不是先来的一定能先得到锁。
ReentrantLock提供了一个构造方法,可以实现公平锁或非公平锁:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
虽然公平锁在公平性得以保障,但因为公平的获取锁没有考虑到操作系统对线程的调度因素以及其他因素,会影响性能。
虽然非公平模式效率比较高,但是非公平模式在申请获取锁的线程足够多,那么可能会造成某些线程长时间得不到锁,这就是非公平锁的“饥饿”问题。
但大部分情况下我们使用非公平锁,因为其性能比公平锁好很多。但是公平锁能够避免线程饥饿,某些情况下也很有用。
接下来看看ReentrantLock公平锁的实现:
# ReentrantLock::lock公平锁模式实现
首先需要在构建函数中传入 true 创建好公平锁
ReentrantLock reentrantLock = new ReentrantLock(true);
调用 lock() 进行上锁,直接 acquire(1) 上锁
public void lock() {
// 调用的sync的子类FairSync的lock()方法:ReentrantLock.FairSync.lock()
sync.lock();
}
final void lock() {
// 调用AQS的acquire()方法获取锁,传的值为1
acquire(1);
}
直接尝试获取锁,
// AbstractQueuedSynchronizer.acquire()
public final void acquire(int arg) {
// 尝试获取锁
// 如果失败了,就排队
if(!tryAcquire(arg) &&
// 注意addWaiter()这里传入的节点模式为独占模式
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();
}
具体获取锁流程
- getState()获取同步状态 值,进行判断是否为0:
- 如果状态变量的值为0,说明暂时还没有人占有锁,使用hasQueuedPredecessors()保证
了不论是新的线程还是已经排队的线程都顺序使用锁,如果没有其它线程在排队,那么当前
线程尝试更新state的值为1,并自己设置到exclusiveOwnerThread变量中,供后续自己可重入获取锁作准备。
- 如果exclusiveOwnerThread中为当前线程说明本身就占有着锁,现在又尝试获取锁,需要将状态变量的值state+1
// ReentrantLock.FairSync.tryAcquire()
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// 状态变量的值为0,说明暂时还没有线程占有锁
if(c == 0) {
// hasQueuedPredecessors()保证了不论是新的线程还是已经排队的线程都顺序使用锁
if(!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
// 当前线程获取了锁,并将本线程设置到exclusiveOwnerThread变量中,
//供后续自己可重入获取锁作准备
setExclusiveOwnerThread(current);
return true;
}
}
// 之所以说是重入锁,就是因为在获取锁失败的情况下,还会再次判断是否当前线程已经持有锁了
else if(current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if(nextc < 0) throw new Error("Maximum lock count exceeded");
// 设置到state中
// 因为当前线程占有着锁,其它线程只会CAS把state从0更新成1,是不会成功的
// 所以不存在竞争,自然不需要使用CAS来更新
setState(nextc);
return true;
}
return false;
}
如果获取失败加入队列里,那具体怎么处理呢?通过自旋的方式,队列中线程不断进行尝试获取锁操作,中间是可以通过中断的方式打断,
如果当前节点的前一个节点为head节点,则说明轮到自己获取锁了,调用 tryAcquire() 方法再次尝试获取锁
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
// 自旋
for(;;) {
// 当前节点的前一个节点,
final Node p = node.predecessor();
// 如果当前节点的前一个节点为head节点,则说明轮到自己获取锁了
// 调用ReentrantLock.FairSync.tryAcquire()方法再次尝试获取锁
if(p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
// 未失败
failed = false;
return interrupted;
}
// 是否需要阻塞
if(shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true;
}
} finally {
if(failed)
// 如果失败了,取消获取锁
cancelAcquire(node);
}
}
当前的Node的上一个节点不是Head,是需要判断是否需要阻塞,以及寻找安全点挂起。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 上一个节点的等待状态
int ws = pred.waitStatus;
// 等待状态为SIGNAL(等待唤醒),直接返回true
if(ws == Node.SIGNAL) return true;
// 前一个节点的状态大于0,已取消状态
if(ws > 0) {
// 把前面所有取消状态的节点都从链表中删除
do {
node.prev = pred = pred.prev;
} while(pred.waitStatus > 0);
pred.next = node;
} else {
// 前一个Node的状态小于等于0,则把其状态设置为等待唤醒
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
在看完获取锁的流程,那么你知道ReentrantLock如何实现公平锁了吗?其实就是在 tryAcquire() 的实现中。
# ReentrantLock如何实现公平锁?
在 tryAcquire() 的实现中使用了 hasQueuedPredecessors() 保证了线程先进先出FIFO的使用锁,不会产生"饥饿"问题,
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// 状态变量的值为0,说明暂时还没有线程占有锁
if(c == 0) {
// hasQueuedPredecessors()保证了不论是新的线程还是已经排队的线程都顺序使用锁
if(!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
....
}...
}
}
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}
tryAcquire都会检查CLH队列中是否仍有前驱的元素,如果仍然有那么继续等待,通过这种方式来保证先来先服务的原则。
那这样ReentrantLock如何实现可重入?是怎么重入的?
# ReentrantLock如何实现可重入?
其实也很简单,在获取锁后,设置一个标识变量为当前线程 exclusiveOwnerThread ,当线程再次进入判断 exclusiveOwnerThread 变量是否等于本线程来判断.
protected final boolean tryAcquire(int acquires) {
// 状态变量的值为0,说明暂时还没有线程占有锁
if(c == 0) {
if(!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
// 当前线程获取了锁,并将本线程设置到exclusiveOwnerThread变量中,
//供后续自己可重入获取锁作准备
setExclusiveOwnerThread(current);
return true;
}
} //之所以说是重入锁,就是因为在获取锁失败的情况下,还会再次判断是否当前线程已经持有锁了
else if(current == getExclusiveOwnerThread()) {
...
}
}
当看完公平锁获取锁的流程,那其实我们也了解非公平锁获取锁,那我们来看看。
# ReentrantLock公平锁模式与非公平锁获取锁的区别?
其实非公平锁获取锁获取区别主要在于:
构建函数中传入 false 或者为null,为创建非公平锁 NonfairSync , true 创建公平锁,
非公平锁在获取锁的时候,先去检查 state 状态,再直接执行 aqcuire(1) ,这样可以提高效率,
final void lock() {
if(compareAndSetState(0, 1))
//修改同步状态的值成功的话,设置当前线程为独占的线程
setExclusiveOwnerThread(Thread.currentThread());
else
//获取锁
acquire(1);
}
- 在 tryAcquire() 中没有 hasQueuedPredecessors() 保证了不论是新的线程还是已经排队的线程都顺序使用锁。
其他功能都类似。在理解了获取锁下,我们更好理解ReentrantLock::unlock()锁的释放,也比较简单。
# ReentrantLock::unlock()释放锁,如何唤醒等待队列中的线程?
- 释放当前线程占用的锁
protected final boolean tryRelease(int releases) {
// 计算释放后state值
int c = getState() - releases;
// 如果不是当前线程占用锁,那么抛出异常
if(Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException();
boolean free = false;
if(c == 0) {
// 锁被重入次数为0,表示释放成功
free = true;
// 清空独占线程
setExclusiveOwnerThread(null);
}
// 更新state值
setState(c);
return free;
}
若释放成功,就需要唤醒等待队列中的线程,先查看头结点的状态是否为SIGNAL,如果是则唤醒头结点的下个节点关联的线程,如果释放失败那么返回false表示解锁失败。
设置waitStatus为0,
当头结点下一个节点不为空的时候,会直接唤醒该节点,如果该节点为空,则会队尾开始向前遍历,找到最后一个不为空的节点,然后唤醒。
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if(ws < 0) compareAndSetWaitStatus(node, ws, 0);
Node s = node.next; //这里的s是头节点(现在是头节点持有锁)的下一个节点,也就是期望唤醒的节点
if(s == null || s.waitStatus > 0) {
s = null;
for(Node t = tail; t != null && t != node; t = t.prev)
if(t.waitStatus <= 0) s = t;
}
if(s != null) LockSupport.unpark(s.thread); //唤醒s代表的线程
}
综合上面的ReentrantLock的可重入,可实现公平\非公平锁的特性外,还具有哪些特性?
# ReentrantLock除了可重入还有哪些特性?
支持线程中断,只是在线程上增加一个中断标志 interrupted ,并不会对运行中的线程有什么影响,具体需要根据这个中断标志干些什么,用户自己去决定。比如,实现了等待锁的时候,5秒没有获取到锁,中断等待,线程继续做其它事情。
超时机制,在 ReetrantLock::tryLock(long timeout, TimeUnit unit) 提供了超时获取锁的功能。它的语义是在指定的时间内如果获取到锁就返回true,获取不到则返回false。这种机制避免了线程无限期的等待锁释放。
# ReentrantLock与Synchrionized的区别
ReentrantLock支持等待可中断,可以中断等待中的线程
ReentrantLock可实现公平锁
ReentrantLock可实现选择性通知,即可以有多个Condition队列
# ReentrantLock使用场景
1:如果已加锁,则不再重复加锁,多用于进行非重要任务防止重复执行,如,清除无用临时文件,检查某些资源的可用性,数据备份操作等
场景2:如果发现该操作已经在执行,则尝试等待一段时间,等待超时则不执行,防止由于资源处理不当长时间占用导致死锁情况
场景3:如果发现该操作已经加锁,则等待一个一个加锁,主要用于对资源的争抢(如:文件操作,同步消息发送,有状态的操作等)
场景4:可中断锁,取消正在同步运行的操作,来防止不正常操作长时间占用造成的阻塞
# 让我们聊一聊Java并发之Synchronized
# 题目描述
Java并发之Synchronized
# 题目解决
# Synchronized简介
线程安全是并发编程中的至关重要的,造成线程安全问题的主要原因:
临界资源, 存在共享数据
多线程共同操作共享数据
而Java关键字synchronized,为多线程场景下防止临界资源访问冲突提供支持, 可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块操作共享数据。
即当要执行代码使用synchronized关键字时,它将检查锁是否可用,然后获取锁,执行代码,最后再释放锁。而synchronized有三种使用方式:
synchronized方法: synchronized当前实例对象,进入同步代码前要获得当前实例的锁
synchronized静态方法: synchronized当前类的class对象 ,进入同步代码前要获得当前类对象的锁
synchronized代码块:synchronized括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
# Synchronized方法
首先看一下没有使用synchronized关键字,如下:
public class ThreadNoSynchronizedTest {
public void method1() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("method1");
}
public void method2() {
System.out.println("method2");
}
public static void main(String[] args) {
ThreadNoSynchronizedTest tnst = new ThreadNoSynchronizedTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
tnst.method1();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
tnst.method2();
}
});
t1.start();
t2.start();
}
}
在上述的代码中,method1比method2多了2s的延时,因此在t1和t2线程同时执行的情况下,执行结果:
method2
method1
当method1和method2使用了synchronized关键字后,代码如下:
public synchronized void method1() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("method1");
}
public synchronized void method2() {
System.out.println("method2");
}
此时,由于method1占用了锁,因此method2必须要等待method1执行完之后才能执行,执行结果:
method1
method2
因此synchronized锁定是当前的对象,当前对象的synchronized方法在同一时间只能执行其中的一个,另外的synchronized方法需挂起等待,但不影响非synchronized方法的执行。下面的synchronized方法和synchronized代码块(把整个方法synchronized(this)包围起来)等价的。
public synchronized void method1() {}
public void method2() {
synchronized(this) {}
}
# Synchronized静态方法
synchronized静态方法是作用在整个类上面的方法,相当于把类的class作为锁,示例代码如下:
public class TreadSynchronizedTest {
public static synchronized void method1() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("method1");
}
public static void method2() {
synchronized(TreadTest.class) {
System.out.println("method2");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
TreadSynchronizedTest.method1();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
TreadSynchronizedTest.method2();
}
});
t1.start();
t2.start();
}
}
由于将class作为锁,因此method1和method2存在着竞争关系,method2中synchronized(ThreadTest.class)等同于在method2的声明时void前面直接加上synchronized。上述代码的执行结果仍然是先打印出method1的结果:
method1
method2
# Synchronized代码块
synchronized代码块应用于处理临界资源的代码块中,不需要访问临界资源的代码可以不用去竞争资源,减少了资源间的竞争,提高代码性能。示例代码如下:
private Object obj = new Object();
public void method1() {
System.out.println("method1 start");
synchronized(obj) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("method1 end");
}
}
public void method2() {
System.out.println("method2 start");
// 延时10ms,让method1线获取到锁obj
try {
Thread.sleep(10);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
synchronized(obj) {
System.out.println("method2 end");
}
}
public static void main(String[] args) {
TreadSynchronizedTest tst = new TreadSynchronizedTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
tst.method1();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
tst.method2();
}
});
t1.start();
t2.start();
}
}
执行结果如下:
method1 start
method2 start
method1 end
method2 end
上述代码中,执行method2方法,先打印出 method2 start, 之后执行同步块,由于此时obj被method1获取到,method2只能等到method1执行完成后再执行,因此先打印method1 end,然后在打印method2 end。
# Synchronized原理
synchronized 是JVM实现的一种锁,其中锁的获取和释放分别是monitorenter 和 monitorexit 指令。
加了 synchronized 关键字的代码段,生成的字节码文件会多出 monitorenter 和 monitorexit 两条指令,并且会多一个 ACC_SYNCHRONIZED 标志位,
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。
在方法执行期间,其他任何线程都无法再获得同一个monitor对象。其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
在Java1.6之后,sychronized在实现上分为了偏向锁、轻量级锁和重量级锁,其中偏向锁在 java1.6 是默认开启的,轻量级锁在多线程竞争的情况下会膨胀成重量级锁,有关锁的数据都保存在对象头中。
偏向锁:在只有一个线程访问同步块时使用,通过CAS操作获取锁
轻量级锁:当存在多个线程交替访问同步快,偏向锁就会升级为轻量级锁。当线程获取轻量级锁失败,说明存在着竞争,轻量级锁会膨胀成重量级锁,当前线程会通过自旋(通过CAS操作不断获取锁),后面的其他获取锁的线程则直接进入阻塞状态。
重量级锁:锁获取失败则线程直接阻塞,因此会有线程上下文的切换,性能最差。
# 锁优化-适应性自旋(Adaptive Spinning)
从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。
其中解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
# 锁优化-锁粗化(Lock Coarsening)
锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append() {
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
# 锁优化-锁消除(Lock Elimination)
锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:
public class SynchronizedTest02 {
public static void main(String[] args) {
SynchronizedTest02 test02 = new SynchronizedTest02();
for(int i = 0; i < 10000; i++) {
i++;
}
long start = System.currentTimeMillis();
for(int i = 0; i < 100000000; i++) {
test02.append("abc", "def");
}
System.out.println("Time=" + (System.currentTimeMillis() - start));
}
public void append(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
}
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。
# Sychronized缺点
Sychronized会让没有得到锁的资源进入Block状态,争夺到资源之后又转为Running状态,这个过程涉及到操作系统用户模式和内核模式的切换,代价比较高。
Java1.6为 synchronized 做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。
# 如何设计一个高并发系统?
# 题目描述
面试官有时在面试中会直接问你:你是如何设计一个高并发系统?
# 面试官心理分析(解题思维方向)
说实话,如果面试官问你这个题目,那么你必须要使出全身吃奶劲了。为啥?因为你没看到现在很多公司招聘的 JD 里都是说啥,有高并发就经验者优先。
如果你确实有真才实学,在互联网公司里干过高并发系统,那你确实拿 offer 基本如探囊取物,没啥问题。面试官也绝对不会这样来问你,否则他就是蠢。
假设你在某知名电商公司干过高并发系统,用户上亿,一天流量几十亿,高峰期并发量上万,甚至是十万。那么人家一定会仔细盘问你的系统架构,你们系统啥架构?怎么部署的?部署了多少台机器?缓存咋用的?MQ 咋用的?数据库咋用的?就是深挖你到底是如何扛住高并发的。
因为真正干过高并发的人一定知道,脱离了业务的系统架构都是在纸上谈兵,真正在复杂业务场景而且还高并发的时候,那系统架构一定不是那么简单的,用个 redis,用 mq 就能搞定?当然不是,真实的系统架构搭配上业务之后,会比这种简单的所谓“高并发架构”要复杂很多倍。
如果有面试官问你个问题说,如何设计一个高并发系统?那么不好意思,一定是因为你实际上没干过高并发系统。面试官看你简历就没啥出彩的,感觉就不咋地,所以就会问问你,如何设计一个高并发系统?其实说白了本质就是看看你有没有自己研究过,有没有一定的知识积累。
最好的当然是招聘个真正干过高并发的哥儿们咯,但是这种哥儿们人数稀缺,不好招。所以可能次一点的就是招一个自己研究过的哥儿们,总比招一个啥也不会的哥儿们好吧!
所以这个时候你必须得做一把个人秀了,秀出你所有关于高并发的知识!
# 面试题剖析
其实所谓的高并发,如果你要理解这个问题呢,其实就得从高并发的根源出发,为啥会有高并发?为啥高并发就很牛逼?
我说的浅显一点,很简单,就是因为刚开始系统都是连接数据库的,但是要知道数据库支撑到每秒并发两三千的时候,基本就快完了。所以才有说,很多公司,刚开始干的时候,技术比较 low,结果业务发展太快,有的时候系统扛不住压力就挂了。
当然会挂了,凭什么不挂?你数据库如果瞬间承载每秒 5000/8000,甚至上万的并发,一定会宕机,因为比如 mysql 就压根儿扛不住这么高的并发量。
所以为啥高并发牛逼?就是因为现在用互联网的人越来越多,很多 app、网站、系统承载的都是高并发请求,可能高峰期每秒并发量几千,很正常的。如果是什么双十一之类的,每秒并发几万几十万都有可能。
那么如此之高的并发量,加上原本就如此之复杂的业务,咋玩儿?真正厉害的,一定是在复杂业务系统里玩儿过高并发架构的人,但是你没有,那么我给你说一下你该怎么回答这个问题:
可以分为以下 6 点:
系统拆分
缓存
MQ
分库分表
读写分离
ElasticSearch
# 系统拆分
将一个系统拆分为多个子系统,用 dubbo 来搞。然后每个系统连一个数据库,这样本来就一个库,现在多个数据库,不也可以扛高并发么。
# 缓存
缓存,必须得用缓存。大部分的高并发场景,都是读多写少,那你完全可以在数据库和缓存里都写一份,然后读的时候大量走缓存不就得了。毕竟人家 redis 轻轻松松单机几万的并发。所以你可以考虑考虑你的项目里,那些承载主要请求的读场景,怎么用缓存来抗高并发。
# MQ
MQ,必须得用 MQ。可能你还是会出现高并发写的场景,比如说一个业务操作里要频繁搞数据库几十次,增删改增删改,疯了。那高并发绝对搞挂你的系统,你要是用 redis 来承载写那肯定不行,人家是缓存,数据随时就被 LRU 了,数据格式还无比简单,没有事务支持。所以该用 mysql 还得用 mysql啊。那你咋办?用 MQ 吧,大量的写请求灌入 MQ 里,排队慢慢玩儿,后边系统消费后慢慢写,控制在 mysql 承载范围之内。所以你得考虑考虑你的项目里,那些承载复杂写业务逻辑的场景里,如何用MQ 来异步写,提升并发性。MQ 单机抗几万并发也是 ok 的,这个之前还特意说过。
# 分库分表
分库分表,可能到了最后数据库层面还是免不了抗高并发的要求,好吧,那么就将一个数据库拆分为多个库,多个库来扛更高的并发;然后将一个表拆分为多个表,每个表的数据量保持少一点,提高 sql 跑的性能。
# 读写分离
读写分离,这个就是说大部分时候数据库可能也是读多写少,没必要所有请求都集中在一个库上吧,可以搞个主从架构,主库写入,从库读取,搞一个读写分离。读流量太多的时候,还可以加更多的从库。
# ElasticSearch
Elasticsearch,简称 es。es 是分布式的,可以随便扩容,分布式天然就可以支撑高并发,因为动不动就可以扩容加机器来扛更高的并发。那么一些比较简单的查询、统计类的操作,可以考虑用 es 来承载,还有一些全文搜索类的操作,也可以考虑用 es 来承载。
上面的 6 点,基本就是高并发系统肯定要干的一些事儿,大家可以仔细结合之前讲过的知识考虑一下,到时候你可以系统的把这块阐述一下,然后每个部分要注意哪些问题,之前都讲过了,你都可以阐述阐述,表明你对这块是有点积累的。
# 考题变形
并发时需要注意的事项
高并发的设计是怎么做的
# 总结
其实实际上在真正的复杂的业务系统里,做高并发要远远比上面提到的点要复杂几十倍到上百倍。
你需要考虑:哪些需要分库分表,哪些不需要分库分表,单库单表跟分库分表如何 join,哪些数据要放到缓存里去,放哪些数据才可以扛住高并发的请求,你需要完成对一个复杂业务系统的分析之后,然后逐步逐步的加入高并发的系统架构的改造
# 谈一谈并发CAS(Compare and Swap)实 现
# 题目描述
谈一谈并发CAS(Compare and Swap)实现
# 题目解决
# 1. 什么是乐观锁与悲观锁?
# 悲观锁
总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:
传统的关系型数据库使用这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁;
Java里面的同步 synchronized 关键字的实现。
# 乐观锁
乐观锁,其实就是一种思想,总是认为不会产生并发问题,每次读取数据的时候都认为其他线程不会修改数据,所以不上锁,但是在更新的时候会判断一下在此期间别的线程有没有修改过数据,乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。实现方式:
CAS实现:Java中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种CAS实现方式,CAS分析看下节。
版本号控制:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功
乐观锁适用于读多写少的情况下(多读场景),悲观锁比较适用于写多读少场景
# 2.乐观锁的实现方式-CAS(Compare and Swap),CAS(Compare and Swap)实现原理
# 背景
在jdk1.5之前都是使用 synchronized 关键字保证同步, synchronized 保证了无论哪个线程持有共享变量的锁,都会采用独占的方式来访问这些变量,导致会存在这些问题:
在多线程竞争下,加锁、释放锁会导致较多的上下文切换和调度延时,引起性能问题
如果一个线程持有锁,其他的线程就都会挂起,等待持有锁的线程释放锁。
如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能风险
为了优化悲观锁这些问题,就出现了乐观锁:
假设没有并发冲突,每次不加锁操作同一变量,如果有并发冲突导致失败,则重试直至成功。
# CAS(Compare and Swap)原理
CAS 全称是 compare and swap(比较并且交换),是一种用于在多线程环境下实现同步功能的机制,其也是无锁优化,或者叫自旋,还有自适应自旋。
在jdk中, CAS 加 volatile 关键字作为实现并发包的基石。没有CAS就不会有并发包,java.util.concurrent中借助了CAS指令实现了一种区别于synchronized的一种乐观锁。
乐观锁的一种典型实现机制(CAS):
乐观锁主要就是两个步骤:
冲突检测
数据更新
当多个线程尝试使用CAS同时更新同一个变量时,只有一个线程可以更新变量的值,其他的线程都会失败,失败的线程并不会挂起,而是告知这次竞争中失败了,并可以再次尝试。
在不使用锁的情况下保证线程安全,CAS实现机制中有重要的三个操作数:
需要读写的内存位置(V)
预期原值(A)
新值(B)
首先先读取需要读写的内存位置(V),然后比较需要读写的内存位置(V)和预期原值(A),如果内存位置与预期原值的A相匹配,那么将内存位置的值更新为新值B。如果内存位置与预期原值的值不匹配,那么处理器不会做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。具体可以分成三个步骤:
读取(需要读写的内存位置(V))
比较(需要读写的内存位置(V)和预期原值(A)
写回(新值(B))
# 3. CAS在JDK并发包中的使用
在JDK1.5以上 java.util.concurrent(JUC java并发工具包)是基于CAS算法实现的,相比于synchronized独占锁,堵塞算法,CAS是非堵塞算法的一种常见实现,使用乐观锁JUC在性能上有了很大的提升。
CAS如何在不使用锁的情况下保证线程安全,看并发包中的原子操作类AtomicInteger::getAndIncrement()方法(相当于i++的操作):
// AtomicInteger中
//value的偏移量
private static final long valueOffset;
//获取值
private volatile int value;
//设置value的偏移量
static {
try {
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) {
throw new Error(ex);
}
}
//增加1
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
首先value必须使用了volatile修饰,这就保证了他的可见性与有序性
需要初始化value的偏移量
unsafe.getAndAddInt通过偏移量进行CAS操作,每次从内存中读取数据然后将数据进行+1操作,然后对原数据,+1后的结果进行CAS操作,成功的话返回结果,否则重试直到成功为止。
//unsafe中
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
//使用偏移量获取内存中value值
var5 = this.getIntVolatile(var1, var2);
//比较并value加+1
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
JAVA实现CAS的原理,unsafe::compareAndSwapInt是借助C来调用CPU底层指令实现的。下面是sun.misc.Unsafe::compareAndSwapInt()方法的源代码:
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
# 4. CAS的缺陷
# ABA问题
在多线程场景下CAS会出现ABA问题,例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下
线程1,期望值为A,欲更新的值为B
线程2,期望值为A,欲更新的值为B
线程3,期望值为B,欲更新的值为A
线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B,
这个时候出现了线程3,线程3取值与期望的值B比较,发现相等则将值更新为A
此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程。
ABA问题带来的危害:
小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。
解决方法
- AtomicStampedReference 带有时间戳的对象引用来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(V expectedReference, //预期引用
V newReference, //更新后的引用
int expectedStamp, //预期标志
int newStamp //更新后的标志
)
- 在变量前面加上版本号,每次变量更新的时候变量的版本号都+1,即A->B->A就变成了1A->2B->3A
# 循环时间长开销大
自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来极大的执行开销。
解决方法:
限制自旋次数,防止进入死循环
JVM能支持处理器提供的pause指令那么效率会有一定的提升,
# 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性
解决方法:
如果需要对多个共享变量进行操作,可以使用加锁方式(悲观锁)保证原子性,
可以把多个共享变量合并成一个共享变量进行CAS操作。
# Java提供了哪几种线程池?
# 题目描述
Java提供了哪几种线程池?
# 解题思路
需要从 什么是线程池?为什么需要线程池?线程池有哪几种已经创建方式等作答
# 什么是线程池
线程池是一种多线程处理形式,处理过程中将任务添加到队列,然后在创建线程后自动启动这些任务。
线程池线程都是后台线程。每个线程都使用默认的堆栈大小,以默认的优先级运行,并处于多线程单元中。如果某个线程在托管代码中空闲(如正在等待某个事件),则线程池将插入另一个辅助线程来使所有处理器保持繁忙。如果所有线程池线程都始终保持繁忙,但队列中包含挂起的工作,则线程池将在一段时间后创建另一个辅助线程但线程的数目永远不会超过最大值。超过最大值的线程可以排队,但他们要等到其他线程完成后才启动。
# 为什么需要线程池
我们有两种常见的创建线程的方法,一种是继承Thread类,一种是实现Runnable的接口,Thread类其实也是实现了Runnable接口。但是我们创建这两种线程在运行结束后都会被虚拟机销毁,如果线程数
量多的话,频繁的创建和销毁线程会大大浪费时间和效率,更重要的是浪费内存。那么有没有一种方法能让线程运行完后不立即销毁,而是让线程重复使用,继续执行其他的任务哪?
这就是线程池的由来,很好的解决线程的重复利用,避免重复开销
# 线程池的优点?
1)重用存在的线程,减少对象创建销毁的开销。
2)可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。
3)提供定时执行、定期执行、单线程、并发数控制等功能。
# Java 提供了哪几种线程池?
# Java主要提供了下面4种线程池
FixedThreadPool: 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
ScheduledThreadPoolExecutor: 主要用来在给定的延迟后运行任务,或者定期执行任务。
ScheduledThreadPoolExecutor又分为:ScheduledThreadPoolExecutor(包含多个线程)和SingleThreadScheduledExecutor (只包含一个线程)两种。
# 4种线程池各自的使用场景是什么?
FixedThreadPool: 适用于为了满足资源管理需求,而需要限制当前线程数量的应用场景。它适用于负载比较重的服务器;
SingleThreadExecutor: 适用于需要保证顺序地执行各个任务并且在任意时间点,不会有多个线程是活动的应用场景;
CachedThreadPool: 适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器;
ScheduledThreadPoolExecutor: 适用于需要多个后台执行周期任务,同时为了满足资源管理需求而需要限制后台线程的数量的应用场景;
SingleThreadScheduledExecutor: 适用于需要单个后台线程执行周期任务,同时保证顺序地执行各个任务的应用场景。
# 创建线程池的方式
(1) 使用 Executors 创建
我们上面刚刚提到了 Java 提供的几种线程池,通过 Executors 工具类我们可以很轻松的创建我们上面说的几种线程池。但是实际上我们一般都不是直接使用Java提供好的线程池,另外在《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
public abstract class Reader implements Readable, Closeable {
protected Object lock;
protected Reader() {
this.lock = this;
}
protected Reader(Object lock) {
if(lock == null) {
throw new NullPointerException();
}
this.lock = lock;
}
//试图将字符读入指定的字符缓冲区。缓冲区可照原样用作字符的存储库:所做的唯一改变是 put 操作的结果。不对缓冲区执行翻转或重绕操作。
public int read(java.nio.CharBuffer target) throws IOException {}
//读取单个字符。在字符可用、发生 I/O 错误或者已到达流的末尾前,此方法一直阻塞。 用于支持高效的单字符输入的子类应重写此方法。
public int read() throws IOException {}
//将字符读入数组。在某个输入可用、发生 I/O 错误或者已到达流的末尾前,此方法一直阻塞。
public int read(char cbuf[]) throws IOException {}
// 将字符读入数组的某一部分。在某个输入可用、发生 I/O 错误或者到达流的末尾前,此方法一直阻塞。
abstract public int read(char cbuf[], int off, int len) throws IOException;
//跳过字符。在某个字符可用、发生 I/O 错误或者已到达流的末尾前,此方法一直阻塞。
public long skip(long n) throws IOException {}
//判断是否准备读取此流。
public boolean ready() throws IOException {}
//判断此流是否支持 mark() 操作。默认实现始终返回 false。子类应重写此方法。
public boolean markSupported() {}
//标记流中的当前位置。对 reset() 的后续调用将尝试将该流重新定位到此点。并不是所有的字符输入流都支持 mark() 操作。
public void mark(int readAheadLimit) throws IOException {}
//重置该流。如果已标记该流,则尝试在该标记处重新定位该流。如果已标记该流,则以适用于特定流的某种方式尝试重置该流,
//例如,通过将该流重新定位到其起始点。并不是所有的字符输入流都支持 reset() 操作,有些支持
reset() 而不支持 mark()。
public void reset() throws IOException {}
//关闭该流并释放与之关联的所有资源。在关闭该流后,再调用 read()、ready()、mark()、reset() 或 skip() 将抛出 IOException。关闭以前关闭的流无效。
abstract public void close() throws IOException;
}
(2) ThreadPoolExecutor的构造函数创建
我们可以自己直接调用 ThreadPoolExecutor 的构造函数来自己创建线程池。在创建的同时,给BlockQueue 指定容量就可以了。示例如下:
private static ExecutorService executor = new ThreadPoolExecutor(13, 13, 60 L, TimeUnit.SECONDS, new ArrayBlockingQueue(13));
这种情况下,一旦提交的线程数超过当前可用线程数时,就会抛出
java.util.concurrent.RejectedExecutionException,这是因为当前线程池使用的队列是有边界队列,队列已经满了便无法继续处理新的请求。但是异常(Exception)总比发生错误(Error)要好。
(3) 使用开源类库
Hollis 大佬之前在他的文章中也提到了:“除了自己定义ThreadPoolExecutor外。还有其他方法。这个时候第一时间就应该想到开源类库,如apache和guava等。”他推荐使用guava提供的ThreadFactoryBuilder来创建线程池。下面是参考他的代码示例:
public class ExecutorsDemo {
private static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build();
private static ExecutorService pool = new ThreadPoolExecutor(5, 200, 0 L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue < Runnable > (1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
public static void main(String[] args) {
for(int i = 0; i < Integer.MAX_VALUE; i++) {
pool.execute(new SubThread());
}
}
}
通过上述方式创建线程时,不仅可以避免OOM的问题,还可以自定义线程名称,更加方便的出错的时候溯源。
# ThreadLocal的内存泄露的原因分析以及如何避免
# 题目描述
ThreadLocal的内存泄露的原因分析以及如何避免
# 题目解决
# 内存泄露
内存泄露为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光,
广义并通俗的说,就是:不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。
# 强引用与弱引用
强引用,使用最普遍的引用,一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。
如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。
弱引用,JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。可以在缓存中使用弱引用。
# GC回收机制-如何找到需要回收的对象
JVM如何找到需要回收的对象,方式有两种:
引用计数法:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收,
可达性分析法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,那么虚拟机就判断是可回收对象。
引用计数法,可能会出现A 引用了 B,B 又引用了 A,这时候就算他们都不再使用了,但因为相互引用 计数器=1 永远无法被回收。
# ThreadLocal的内存泄露分析
先从前言的了解了一些概念(已懂忽略),接下来我们开始正式的来理解ThreadLocal导致的内存泄露的解析。
# 实现原理
static class ThreadLocalMap {
static class Entry extends WeakReference < ThreadLocal < ? >> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal < ? > k, Object v) {
super(k);
value = v;
}
}...
}
12345678910111213
ThreadLocal的实现原理,每一个Thread维护一个ThreadLocalMap,key为使用弱引用的ThreadLocal实例,value为线程变量的副本。这些对象之间的引用关系如下,
实心箭头表示强引用,空心箭头表示弱引用
# ThreadLocal内存泄漏的原因
从上图中可以看出,hreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时,Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉。
但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
永远无法回收,造成内存泄漏。
# 那为什么使用弱引用而不是强引用??
我们看看Key使用的
# key 使用强引用
当hreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
# key使用弱引用
当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null,在下一次ThreadLocalMap调用set(),get(),remove()方法的时候会被清除value值。
# ThreadLocalMap的remove()分析
在这里只分析remove()方式,其他的方法可以查看源码进行分析:
private void remove(ThreadLocal < ? > key) {
//使用hash方式,计算当前ThreadLocal变量所在table数组位置
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);
//再次循环判断是否在为ThreadLocal变量所在table数组位置
for(Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if(e.get() == key) {
//调用WeakReference的clear方法清除对ThreadLocal的弱引用
e.clear();
//清理key为null的元素
expungeStaleEntry(i);
return;
}
}
}
123456789101112131415161718
再看看清理key为null的元素expungeStaleEntry(i):
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 根据强引用的取消强引用关联规则,将value显式地设置成null,去除引用
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 重新hash,并对table中key为null进行处理
Entry e;
int i;
for(i = nextIndex(staleSlot, len);
(e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal < ? > k = e.get();
//对table中key为null进行处理,将value设置为null,清除value的引用
if(k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if(h != i) {
tab[i] = null;
while(tab[h] != null) h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
123456789101112131415161718192021222324252627282930313233
# 总结
由于Thread中包含变量ThreadLocalMap,因此ThreadLocalMap与Thread的生命周期是一样长,如果都没有手动删除对应key,都会导致内存泄漏。
但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次
ThreadLocalMap调用set(),get(),remove()的时候会被清除。
因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
# ThreadLocal正确的使用方法
每次使用完ThreadLocal都调用它的remove()方法清除数据
将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。
# Volatile不保证原子性以及解决方案
# 1、原子性的定义
什么是原子性,什么是原子性操作?
举个例子:
A想要从自己的帐户中转1000块钱到B的帐户里。那个从A开始转帐,到转帐结束的这一个过程,称之为一个事务。在这个事务里,要做如下操作:
1.从A的帐户中减去1000块钱。如果A的帐户原来有3000块钱,现在就变成2000块钱了。
2.在B的帐户里加1000块钱。如果B的帐户如果原来有2000块钱,现在则变成3000块钱了。
如果在A的帐户已经减去了1000块钱的时候,忽然发生了意外,比如停电什么的,导致转帐事务意外终止了,而此时B的帐户里还没有增加1000块钱。那么,我们称这个操作失败了,要进行回滚。回滚就是回到事务开始之前的状态,也就是回到A的帐户还没减1000块的状态,B的帐户的原来的状态。此时A的帐户仍然有3000块,B的帐户仍然有2000块。
我们把这种要么一起成功(A帐户成功减少1000,同时B帐户成功增加1000),
要么一起失败(A帐户回到原来状态,B帐户也回到原来状态)的操作叫原子性操作。
如果把一个事务可看作是一个程序,它要么完整的被执行,要么完全不执行。这种特性就叫原子性。
# 2、volatile不保证原子性代码验证
package com.hyq.test;
public class VolatileAtomDemo {
// volatile不保证原子性
// 原子性:保证数据一致性、完整性
volatile int number = 0;
public void addPlusPlus() {
number++;
}
public static void main(String[] args) {
VolatileAtomDemo volatileAtomDemo = new VolatileAtomDemo();
for(int j = 0; j < 20; j++) {
new Thread(() - > {
for(int i = 0; i < 1000; i++) {
volatileAtomDemo.addPlusPlus();
}
}, String.valueOf(j)).start();
}
// 后台默认两个线程:一个是main线程,一个是gc线程
while(Thread.activeCount() > 2) {
Thread.yield();
}
// 如果volatile保证原子性的话,最终的结果应该是20000
// 但是每次程序执行结果都不等于20000
System.out.println(Thread.currentThread().getName() + "\t final number result = " + volatileAtomDemo.number);
}
}
代码执行结果如下:多次执行结果证明volatile不保证原子性
# 3、volatile不保证原子性原理分析
number++被拆分成3个指令
执行GETFIELD拿到主内存中的原始值number
执行IADD进行加1操作
执行PUTFIELD把工作内存中的值写回主内存中
当多个线程并发执行PUTFIELD指令的时候,会出现写回主内存覆盖问题,所以才会导致最终结果不为20000,volatile不能保证原子性。
# 4、解决volatile不保证原子性问题
a、方法前加synchronized解决
public synchronized void addPlusPlus() {
number++;
}
b、加锁解决
Lock lock = new ReentrantLock();
public void addPlusPlus() {
lock.lock();
number++;
lock.unlock();
}
c、原子类解决
package com.hyq.test;
import java.util.concurrent.atomic.AtomicInteger;
public class VolatileSolveAtomDemo {
// 原子Integer类型,保证原子性
private AtomicInteger atomicNumber = new AtomicInteger();
// 底层通过CAS保证原子性
public void addPlusPlus() {
atomicNumber.getAndIncrement();
}
public static void main(String[] args) {
VolatileSolveAtomDemo volatileSolveAtomDemo = new
VolatileSolveAtomDemo();
for(int j = 0; j < 20; j++) {
new Thread(() - > {
for(int i = 0; i < 1000; i++) {
volatileSolveAtomDemo.addPlusPlus();
}
}, String.valueOf(j)).start();
}
// 后台默认两个线程:一个是main线程,一个是gc线程
while(Thread.activeCount() > 2) {
Thread.yield();
}
// 因为volatile不保证原子性,所以选择原子类AtomicInteger来解决volatile不保证原子性问题
// 最终每次程序执行结果都等于20000
System.out.println(Thread.currentThread().getName() + "\tfinal number result = " + volatileSolveAtomDemo.atomicNumber.get());
}
代码执行结果如下:多次执行结果证明原子类是可以解决volatile不保证原子性问题
# Volatile禁止指令重排
# 1、指令重排有序性:
计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一般分为以下三种:
单线程环境里面确保程序最终执行结果和代码顺序执行结果一致。
处理器在进行指令重排序时必须考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器指令重排的存在,两个线程使用的变量能否保证一致性是无法确认的,结果无法预测。
指令重排案例分析one:
public void mySort() {
int x = 11; // 语句1
int y = 12; // 语句2
x = x + 5; // 语句3
y = x * x; // 语句4
}
// 指令重排之后,代码执行顺序有可能是以下几种可能?
// 语句1 -> 语句2 -> 语句3 -> 语句4
// 语句1 -> 语句3 -> 语句2 -> 语句4
// 语句2 -> 语句1 -> 语句3 -> 语句4
// 问题:请问语句4可以重排后变为第1条吗?
// 不能,因为处理器在指令重排时必须考虑指令之间数据依赖性。
指令重排案例分析two:
指令重排案例分析three:
public class BanCommandReSortSeq {
int a = 0;
boolean flag = false;
public void methodOne() {
a = 1;
// 语句1
flag = true;
// 语句2
// methodOne发生指令重排,程序执行顺序可能如下:
// flag = true;
// 语句2
// a = 1;
// 语句1
}
public void methodTwo() {
if(flag) {
a = a + 5;
// 语句3
}
System.out.println("methodTwo ret a = " + a);
}
// 多线程环境中线程交替执行,由于编译器指令重排的存在,两个线程使用的变量能否保证一致性是无法确认的, 结果无法预测。
// 多线程交替调用会出现如下场景:
// 线程1调用methodOne,如果此时编译器进行指令重排
// methodOne代码执行顺序变为:语句2(flag=true) -> 语句1(a=5)
// 线程2调用methodTwo,由于flag=true,如果此时语句1还没有执行(语句2 -> 语句3 -> 语句1 ),那么执行语句3的时候a的初始值=0
// 所以最终a的返回结果可能为 a = 0 + 5 = 5,而不是我们认为的a = 1 + 5 = 6;
}
# 2、禁止指令重排底层原理:
volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。
先了解下概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
保证特定操作执行的顺序性;
保证某些变量的内存可见性(利用该特性实现volatile内存可见性)
volatile实现禁止指令重排优化底层原理:
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排,也就是说通过插入内存屏障,就能禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
左边:写操作场景:先LoadStore指令,后LoadLoad指令。
右边:读操作场景:先LoadLoad指令,后LoadStore指令。
# 3、volatile使用场景
单例模式(DCL-Double Check Lock双端检锁机制)
如果此时你也把volatile禁止指令重排底层原理也解释清楚了,面试官可能会接着问你,你知道volatile使用场景吗?
单例模式(DCL-Double Check Lock双端检锁机制)就是它的使用场景
# volatile可见性详解
volatile是java虚拟机提供的轻量级的同步机制具有以下特点:
1.保证可见性
2.不保证原子性
3.禁止指令重排
接下来我们先看volatile的可见性的特征,以及底层原理的讲解,那么在研究volatile底层原理之前,我们接下来先要研究一个新的知识--JMM,好,这一题还没讲完呢,猴哥你特么又扯出来一个新的知识点JMM呢?这个时候请按照老师的要求来进行学习,听好去年甚至前年,17 18年面试题上一道必考的题目JVM,这个是java虚拟机,只要是java程序员,地球人都知道,但是最近这一两年,由于这道JVM的题目,比如说一言不合就让你画一个JVM的内存图,大家都背过学过,那么用人单位他没有办法挑出最好的java工程师,所以说题目升级,尤其现在非常注重,你在上一家当前家公司,做的是高并发项目还是单机版的系统,那么如果是高并发的系统,自然而然问题频出,为了解决这些问题,你不得不去研究底层,一研究底层,自然而然会来一个东西叫JMM(java内存模型)不是java虚拟机,现在大厂基本上都会跟你聊聊JMM,如果你说不知道,那么没办法你一定没有干过高并发,一定没有做过JUC编程,你也就是传说中的增删改查程序员,你的这个业务也就这么回事,不是找不到工作,而是很难突破18,20甚至25,那么现在我们就要唠唠什么叫java内存模型,它跟我们的volatile有什么关系?首先这道题是谈谈你对volatile的理解。
我们首先对于JMM,我们有一个深度的解析,看一下下面横线之间的文字,简单通读一遍:
JMM(java内存模型Java Memory Model,简称JMM)本身是一个抽象的概念并不真实存在,他描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
JMM关于同步的规定:
1、线程解锁前,必须吧共享变量的值刷新回主内存
2、线程加锁前,必须读取主内存的最新值到自己的工作内存
3、加锁解锁是同一把锁
由于JVM运行程序的实体是线程,而每一个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而java内存模型中规定所有的变量都存储在主内存,主内存是共享内存区域,所有的线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存完成,其简要访问过程如下图:
好大家先把这一段话囫囵吞枣的通读一遍,OK相信大家读完之后,不是一脸懵逼就是满脸懵逼,反正吧都是中文,也理解什么不全,也吃透不了全部的意思,但是你要说一点不懂吧,感觉也能摸到一点边,那么这个时候,看大家满脸懵逼的状态,别读了,第一遍,老师的要求第一遍,学三遍,干吗呢?这一边读完了,那么我就告诉你假设这就是一本书,我会向大家证明为什么你现在必须跟着猴哥学,为什么自己自学、看看书成为高手,那是不可能的,没有老师去带你,你不开挂,那么基本上你的成长有限,OK,咱们开始:
经过上面文字的阅读,出现了几个稍微有点晦涩的名字:主内存、自己的工作内存
首先读完的同学不管懂了多少,别读了,咱们开工,我们先解决JMM模型,好,那么首先我们大家都很明白,对于我们工作当中数据的传递和存储,它的速率基本上是这样,那就是 硬盘<内存,不用多讲,我们的MySQL数据库装在硬盘上,但是数据的存储要大规模的存取之后,我们都明白磁盘有一个硬件绕不开叫IO,是纯硬件的,它有它的上线和限制,达到了一定的阈值以后,很难在突破,除非是硬件上突破,好,但是我们的数据不会因为你存储在硬盘上她就饶了你,尤其是现在大数据时代,要求数据的读写存储都得是很快,最后我们明白了一个道理,部分数据就不要存储在硬盘上了,放哪?内存里面,看都是一个硬件对应一个软件,那这个内存是什么鬼?那么大家之前咱们学过分布式的内存数据库Redis,大家都明白内存的读取速度是不是快过硬盘?好,那么接下来比内存更快的呢???这是时候就是CPU, 这时候有的同学说:昂,那我懂了,将数据存在CPU中 错大错特错,CPU是负责计算的不管存储,这个时候干吗呢?有这么一个情况,假设CPU比内存快,那么在这中间,这段时间读取速度,CPU的计算能力超强,算完了但是数据还运不上来,那怎么办呢?CPU就这么一直耗着?那不行。这个是时候我们一定要明白,在CPU和内存读取这存储之间还有一点空间,那么这个空间也就是所谓的缓存。
如果CPU的速度大于内存,这是必须的,如果CPU一直闲着,是不是会导致CPU性能下降?干脆一部分数据计算好了,我们先把他保存在缓存中去,可以抽象的理解为:首先我们的数据在内存里面,每位同学现在在你们的内存插槽里面也都一条内存条,比如你的内存是8G,这就是我们插在主板上的一个硬件,注意:这个硬件就俗称我们的主内存。我们每new一个对象就是般存在这个主内存中。
比如说我们这个主内存中有一个Student对象age=25 我们这个在物理内存中这个对象仅此一份,接下来,到了高并发的时代,多个线程,就像我们买票一样,假设我们都要改他的年龄,可不可以多线程来改这个年龄?完全可以的。那么大家看,由于CPU太快,那么在缓存这里,有三个线程;都要操作age,是不是直接操作这个对象呢???肯定不是。首先每一个线程都会将主内存中的这一份25 拷贝到自己线程一份这个东西就是各自线程的工作内存,就是说线程要去改,不是直接去修改主物理内存中的这个数据,而是线程一种变量的拷贝,将这个变量25 各自拷贝回各自的工作中去,也就是三个线程每个线程中都有一份25,然后第一个线程t1将25 修改为27 ,改完了,但是线程之间没有办法横向 t2和t3并不知道t1将25改成了37,接下来,在t1线程中将修改后的37写回给主内存,但是不好意思,这个时候t2和t3并不知道主物理内存的值已经从25修改成了37,我们必须要有一种机制,只要有一个线程修改完自己的工作空间的值并写回到主内存中,要及时通知其他线程,这样及时通知的情况就俗称JMM内存模型中的第一个重要特性俗称可见性,能理解???也就是说某一个线程你修改了值并写回了主内存以后,另外的线程要马上能够知道,我手上的这个25 已经不是最新值了,、作废,需要重新回到主内存去拿到最新的值,能理解??这时候请大家跟着老师熟悉什么叫可见性?即一个线程修改了主物理内存的值,主物理内存的值被修改,其他线程马上获得通知,假设今天晚上我们需要加课,只要侯哥跟班主任一说,班主任是不是马上在微信群里面@所有人大家是不是马上立刻就能看到知道今晚要加课?否则今晚你就可能请假走人了。那么也就是说只要有变动大家立马可见,立刻收到最新消息这个机制叫可见性。
OK此时再次阅读一遍,横线之间的内容,看看什么效果?
可见性的代码验证说明:
package com.lagou.test;
import java.util.concurrent.TimeUnit;
class MyData {
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
}
/**
* 验证volatile的可见性
* 1.1 假如int number = 0 ; number变量之前根本没有添加volatile关键字修饰
*/
public class VolatileDemo {
public static void main(String[] args) { //main是一切方法的运行入口
MyData myData = new MyData(); //资源类
new Thread(() - > {
System.out.println(Thread.currentThread().getName() + "\t come in");
//暂停一会儿线程
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addTo60();
System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
}, "AAA").start();
//第二个线程就是我们的main线程
while(myData.number == 0) {
//main线程就一直在这里等待,直到number值不再等于0
}
System.out.println(Thread.currentThread().getName() + "\t mission is over, main get number value: "+myData.number);
}
}
解释说明:
资源类MyData中的number变量如果没有volatile修饰,则运行结果:
如果加上volatile修饰,则运行结果:
因为有了volatile修饰,具有了可见性,AAA线程中将number的修改之后,会立刻通知main线程,number的值修改为了60,则退出死循环,并打印“main mission is over, main get number value:60”