1. synchronized相关
- synchronized是锁的代码块还是对象
sync锁的是对象,实际上借助Object对象的Monitor对象来实现对临界资源加锁。当线程访问的代码存在sync关键字时,会去争夺Monitor资源,当争夺失败后,会存储到Monitor的Entry Set
中阻塞。
当Monitor临界资源被访问时,对象头的mark word
的同步锁状态会表明该对象的锁标识。
- object对象结构(阿里面试题new Object[100]对象大小,它的一个对象引用大小,对象头结构)
答案:new Object[100]占用416字节,其中对象头占用16字节,实例数据+对齐填充占用4*100字节,总共416字节。
JAVA并发(1)—java对象布局
Java并发(13)— 各种Java对象占用内存大小
扩展:
Java对象在内存中的布局可以分为三块区域
-
对象头
部分占用12byte; -
实例数据
部分占用的是1byte(boolean类型),int就会占用4byte; -
对齐填充
部分占用了3byte,将其补充为8的整数倍。
而对象头又由klass pointer和mark word组成。
mark word存储自身运行时的数据,例如hashCode、GC分代年龄、同步锁状态;
klass pointer:对象指向原数据的指针,虚拟机可以通过这个指针确定这个对象是哪个类的实例;
- synchronized的作用范围
- 作用于方法时,锁住的是对象的实例(this);
- 作用于静态方法时,锁住的是Class实例,是类的一个全局锁,会锁所有调用该方法的线程;
- 作用于对象实例时,锁的是所有以该对象给锁的代码块;
- 简述一下什么是重量级锁、轻量级锁、偏向锁
并发访问存在三种情况:
(1)多个线程同时访问;
(2)线程交替访问;
(3)只有一个线程访问;
重量级锁是线程同时访问情况下,使用Object Monitor机制,借助操作系统来进行互斥;
轻量级锁是线程交替访问的情况下,借助CAS完成互斥;
偏向锁是在只有一个线程访问的情况下,某个线程获取到锁之后,减少这个线程重入(CAS)的开销。轻量级锁的获取和释放依赖多次CAS指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,在升级到重量级锁。
- synchronized的缺陷(其实变相问的是synchronized和Lock的区别)
- 无法设置synchronized关键字在获取锁的时候等待时间,所以synchronized可能会导致线程为了加锁而无限期的处于阻塞状态。
- synchronized关键字只支持独占锁,对于读多写少的应用时很不利的。
- synchronized只是非公平锁,没有实现公平锁的功能。
- synchronized加锁流程
- 当只有一个线程访问时,此时加的是偏向锁,只需要置换一次ThreadID即可;
- 当线程交替访问时,此时偏向锁膨胀为轻量级锁,借助CAS完成访问;
- 当多个线程同时访问时,此时锁膨胀为重量级锁,进入
Monitor的线程
会和Monitor中的Wait Set
、Entry Set
去共同的争夺锁资源,这也就是sync是非公平锁的原因。当争夺到Monitor资源后,只有一个线程去进行访问。
- 当静态方法存在锁,会对实例方法的锁产生影响吗?
不会有影响,对静态方法实际上是锁的class实例,而对实例方法加锁,锁的是this对象。因为使用的不是同一个对象,也就不会出现Monitor互斥的情况。
- wait和notify为什么必须放在synchronized代码块中?
wait和notify都是操作monitor的方法,而object内置了monitor对象,synchronized关键字实际上是借助Monitor进行加锁,故需要放在synchronized代码块中。
- wait和notify为什么是Object的方法
wait和notify目的是为了操作Monitor中的线程,使当前线程进入Wait Set
被阻塞或者被唤醒抢夺锁。而只有Object才会设置Monitor对象。
- 悲观锁和乐观锁
乐观锁和悲观锁都是并发控制的主要技术手段。
悲观锁:比较悲观,认为写多读少,遇见并发写的可能性高。故在每次读写数据的时候都会加锁。JAVA中悲观锁代表为Synchronized,AQS框架下的锁会先尝试CAS乐观锁去获取锁,获取不到,才会转换为悲观锁。
乐观锁:认为读多写少。在更新的时候才去判断这个记录有没有被其他线程修改过。采取的是版本号机制。即在写时读出当前版本号,然后加锁操作。如果失败则要重复读-比较-写的操作。
java乐观锁一般是CAS操作来实现的,CAS是一种更新的原子操作,比较当前值和传入值是否一样,一样则更新,否则失败。
- 线程的几种状态
- 新建:线程被new出来
- 就绪:调用线程的start()方法后,线程处于等待CPU分配资源阶段。
- 运行:就绪的线程被调度并获得CPU资源时,便进入运行态。
- 阻塞:运行状态时,调用sleep()、wait()之后线程就处于阻塞态。
- 死亡态:线程运行完毕、线程被强制性终止、线程抛出异常导致结束,线程就要被销毁,释放资源。
- sleep和wait的区别
- 来自不同的类:sleep是Thread类的静态方法,而wait是Object的方法
- 有没有释放锁:sleep不释放同步锁,而wait释放同步锁。
- 使用范围不同:wait必须用在sync代码块中,而Thread没有这个限制。
- 是否需要捕获异常:sleep必须捕获异常,而wait、notify不需要捕获异常。
- 终止线程的方式
- 线程运行结束;
- 使用volatile自定义标志来退出线程;
- 使用Interrupt方法终止线程。
3.1 当线程处于阻塞态时,例如使用sleep方法、wait方法。当调用线程的interrupt方法时,会抛出InterruptException异常,通过代码捕获异常,然后抛出自定义异常或者break结束循环状态。
3.2 线程未处于阻塞状态:需要借助interrupted()判断线程中断标识来退出循环。当使用interrupt()方法时,中断标志置位true,此时和使用自定义标志控制循环是一样的道理。需要注意的是:当调用interrupted()
方法后,若第一次返回true,再次调用则会返回false。 - 使用stop终止(不推荐),强行终止线程,会释放子线程持有的所有锁,可能导致数据的不一致性。
2. AQS相关
AQS实现原理
AbstractQueuedSynchronizer
类如其名,抽象队列式同步器,AQS定义了一套多线程访问共享资源的同步器框架。许多同步器实现都依赖它,如常用的:
ReentrantLock/Semaphore/CountDownLatch
AQS维护了一个volatile int state(代表共享资源)和FIFO线程等待队列(多线程争用资源被阻塞时进入此队列)。
AQS只是一个框架,具体资源获取/释放方式交由自定义同步器去实现。
- AQS加锁流程:
- 尝试获取锁:判断是否无人获取锁或当前线程是否是持有锁的线程;
- 是否排队:因为是公平锁,当锁无人占有时,会判断自己是否需要排队;
-
尾插queue:将线程包装为Node节点,通过尾插法插入
sync queue
中,注意尾分叉的问题,即多个节点指向尾节点; - 线程park():阻塞线程,阻塞之前获取Node的前驱节点,若是head节点,那么尝试使用CAS获取一次锁。若获取锁失败,那么将自己的前驱节点的waitStatus属性设置为-1,然后park自己,等待唤醒。
- AQS解锁流程:
- 只有一个线程获取到锁,那么释放锁就是线程安全的,修改state的值,即释放一次锁。
- 判断head节点的waitStatus是否为0,若不为0,唤醒后续线程,因为加锁时存在尾分叉的问题,需要从尾部遍历,找到距离head节点最近且ws<=0的node节点。
- 然后唤醒Node节点的线程;
- 唤醒的节点会继续抢占锁,若抢占锁成功,才会移除head节点。
- 公平锁不需要排队的几种情况
公平锁和非公平锁的区别在于:当前线程进入后是否需要排队,而公平锁并不是一定要进入阻塞队列排队,下面三种情况即无需排队:
- 此时并未维护队列,即无人排队;
- 队列中只存在head节点;
- 队列中存在多个节点,但是head的next节点为当前线程;
- 尾分叉—当前线程进入阻塞队列
线程被包装为Node节点,且阻塞队列采用的是尾插法:
- 新节点指向尾节点(线程不安全,可能存在多个新节点指向一个尾节点);
- 通过CAS指令将tail指针指向一个新节点(线程安全)
- 旧尾结点指向新尾节点(线程安全)
- 简述AQS共享锁加锁解锁流程
在读写锁中,无论是共享锁还是独占锁,都是依赖一个标识位state来标识上锁状态。只不过共享锁使用高16位来记录锁的状态。
共享锁依赖ThreadLocal来保存线程的重入次数;
共享锁和独占锁都维护在sync queue中,依赖Node节点的nextWaiter来区分共享节点(new Node)还是独占节点(null)。
-
共享锁判读是否排队。
- 公平锁:若head节点后继节点不为自己,排队;
- 非公平锁:若head节点后继节点为共享节点,则不排队;
流程:
- 线程进入后,判断当前锁是否被独占,若不是被自己独占,那么就会去排队。若是被自己独占,那么进行锁降级;
- 判断自己需不需要排队,当然对于共享锁来说也有公平锁和非公平锁;
- 若自己不需要排队,那么就开始争夺锁;
- 若争夺锁失败,或自己需要排队,就会调用操作系统将自己挂起。
- 当共享锁获取锁或者释放锁时,会发起
唤醒风暴
,即线程A唤醒线程B,且线程B获取到锁后,线程A会和线程B同时去唤醒其他节点。
- AQS公平锁为什么会比非公平锁效率低
- 当线程释放锁后,非公平锁会让进入的线程立即抢占锁。而公平锁却要判断进入的线程是否需要排队。
- 公平锁释放后,要唤醒sync queue中的线程,是在尾部进行遍历,然后借助操作系统唤醒等待的线程。
- synchronized和Lock的区别?
- synchronized是java内置关键字,Lock是java API方法;
- synchronized出现异常会自动释放锁,Lock必须在finally中手动释放;
- synchronized是非公平锁,而Lock可以是公平锁;
- synchronized是独占锁,而Lock可以是读写锁;
- synchronized不可中断,而Lock可以中断;
- synchronized不能判断是否获取到锁,而Lock可以判断;
- 读写锁(非公平锁)中,是否写锁会一直抢占不到锁?
读写锁中的公平锁和非公平锁的区别在于判断是否排队。
读写锁中非公平锁:读锁线程进入后也不是直接去抢占锁资源,他也需要判断自己是否排队,不需要排队的三种情况:
- sync queue未维护;
- sync queue维护,但是只有一个head节点;
- sync queue维护,存在多个节点。head后第一个节点必须是共享节点,进入线程才不需要排队。
这样保证了在读写非公平锁中,写锁也有机会去获取到锁。
Condition的原理
每创建一个Condition对象就对应一个Condition队列,每调用一次Condition对象的await方法,当前线程就会被包装为Node节点扔到Condition队列中。
Condition queue是一个单向队列,在链表中使用nextWaiter属性来串联链表。
Condition queue和sync queue的联系
一般情况下,等待锁的sync queue和条件队列condition queue是相互独立的。当我们调用某个条件的signal方法时,将节点从condition中取出,后续唤醒该线程,被唤醒的线程和普通线程一样需要争夺锁,如果没有争夺到,则同样被加入到sync queue中。
- CAS算法
CAS是无锁算法,CAS有3个操作数,分别是内存值V,旧的预期值A,新的修改值B。
当且仅当旧预期值A和内存值V一样时,将V改成B,否则什么都不做。
- CAS的ABA问题
CAS会导致“ABA”问题,CAS算法实现一个重要的前提:需要取出内存中某时刻的数据,而在下时刻比较并替换。那么这个时间差会导致数据的变化。
比如转账场景:
- 用户目的是取出50块钱。
- 提款机硬件出现问题,导致出现了两笔并发请求,两个线程线程都获取当前值100元,要更新成50元;
- 线程1通过CAS成功,此时内存的值为50;
- 线程2被阻塞了一会,此时正好有一个线程3转入50元。线程3转入成功;
- 线程2进行CAS比较。内存值V为100,旧的期待值为100。结果是取款成功;
这就是ABA问题,本质上是:某一时刻取出的值与下一时刻进行比较。时间差会导致数据的变化。
解决ABA问题:通过加版本号的方式来解决ABA问题,JAVA提供了AtomicStampedReference
类来解决ABA问题。本质上是乐观锁机制,在执行数据修改操作时,都会加上一个版本号,一旦版本号与数据的版本号一致才会执行修改操作并对版本号+1操作,否则执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加并不会减少。
CAS的原理
CAS使用乐观锁解决的是JMM模型中,工作内存的值刷新到主存时如何保证原子性。借助于两方面:
- value(内存值V)使用volatile修饰,保证可见性;
- 获取旧预期值A和旧逾期值A与内存值V比较并不是原子性,当比较失败,返回false。并不进行替换。通过代码层面的自旋,来实现原子性的更新。
- 死锁的产生的原因
- 互斥条件:一个资源每次只能一个进程使用,其他进程等待;
- 请求与持有条件:进程A持有一个资源,此时申请其他资源,但其他资源被其他进程占用。进程A在不释放自己资源的前提下被阻塞。
- 不可剥夺条件:进程占用的资源只能由获取资源的进程自己去释放;
- 循环等待条件:若干进程间形成首位相接循环等待资源的关系。
- 如何预防死锁
避免多次锁定、具有相同的加锁顺序,使用定时锁、死锁检测。
3. 线程池相关
- ThreadPoolExecutor的工作原理
- 若小于核心线程数,创建Worker线程,并将传入的任务直接交给Worker线程去执行。
- 若大于核心线程数,会将传入的任务放入workQueue(阻塞队列)中;
- 若阻塞队列满了,依旧会创建Worker线程,并将传入的任务交给Worker线程,直到满足最大线程数;
- 若最大线程数满了,那么采取拒绝策略去拒绝任务;
Worker线程对象实现了Runnbale接口,通过装饰器的模式增强了任务功能,例如调用shutdownNow后可以中断任务;预留钩子方法供用户扩展。
线程池使用HashSet去保存Worker线程;
线程池维护了一个AtomicInterger的ctl属性,前3位标识线程池的状态,后29位标识线程池中线程的数据,并使用CAS来修改该参数。
- 线程池源码总结
也就意味着:线程池是内存级别的MQ,MQ是分布式的线程池。
他俩都是最典型的生产者消费者模型,通过BlockingQueue实现的线程间通信,而BlockingQueue内部实际使用的是AQS的Lock锁实现的阻塞和唤醒。
- 线程池工作原理和BlockingQueue API的关系
线程池工作原理的实现很大程度上是借助BlockingQueue的API。
- 线程池向阻塞队列填充任务,使用的是offer()方法,确保了当阻塞队列满了之后,offer()方法返回false。线程池可以开启最大线程数的worker线程或者拒绝策略处理任务。
- worker监听BlockingQueue中的任务,采用poll()方法,确保最大线程数的worker线程以及超过空闲时间的的核心线程数可以自动关闭。
- ForkJoin pool的理解
ForkJoin Pool适合能够进行拆分再拆分的计算型(CPU密集型)任务,服务器拥有多CPU,多核,拥有提高计算能力。
ForkJoin Pool是并行线程池,核心思想:分治法+工作窃取。
分治法:将一个任务,切分成多个父子关系的子任务;
工作窃取:若线程空闲,将窃取其他线程的任务,即多个线程并行执行任务;
- ForkJoin内部每个worker线程都维护了一个工作队列,这是一个双端队列,里面存储对象是任务;
- worker线程执行
fork()
产生新的任务,会放入工作队列的尾部,并且在队尾取出任务。 - worker线程在处理自己的工作队列时,会尝试在双端队列的队首窃取任务;
- 遇见
join()
,如果需要join的任务尚完成,则会先处理其他任务,直到目标任务被告知已经结束,所有任务都是无阻塞完成。
- 项目关闭的时候,线程池中任务如何被处理?
Java服务实现优雅的关闭:ShutdownHook/Signal回调
JDK线程池对外提供了两个API方法用于在服务下线时对任务的处理:
- shutdown():线程池不能接受新的任务,它会等待所有任务执行完毕;
- shutdownNow():线程池不能接受新的任务,并且尝试终止正在执行的任务;
但是不幸的时,JDK线程池在服务下线的时候并不会调用任何的API方法,任务就会被丢弃;
而Spring的线程池默认情况下,采用的是shutdownNow()的策略来关闭线程池,并且提供了setWaitForTasksToCompleteOnShutdown()和setAwaitTerminationSeconds()方法,来决定在服务下线时是否调用shutdown()方法,以及等待shutdown方法终止的时间。
- 引申:Spring在服务下线时回调方法的原理。
Spring提供的DisposableBean#destory方法,可以在容器销毁的时候进行回调。本质上是调用Runtime.getRuntime().addShutdownHook
方法来注册事件,以便在JVM关闭时来进行回调shutdownHook方法。
JDK提供的Runtime.getRuntime().addShutdownHook
方法,允许用户注册一个JVM关闭的钩子。这个钩子可以在以下几种场景被调用:
- 程序正常退出;
- 使用System.exit();
- 终端使用Ctrl+C触发的终端;
- 系统关闭;
- 使用kill pid命令干掉进程;
一般地发布系统会通过kill命令来停止服务。这个时候服务可以接收到关闭信号并执行钩子程序进行清理工作。
但是使用shutdownHook的时候,往往控制不了钩子的执行顺序,此时可以考虑使用signal机制来实现服务关闭的回调。
- signal实现服务关闭回调
Java同时提供了signal信号机制,我们的服务也可以接收到关闭信号。
使用Signal机制有以下原因:
- ShutdownHook执行顺序无法保障,第三方组件也可能注册,导致业务自定义的退出流程依赖的资源会被提前关闭和清理;
- Signal是非公开API,第三方组件基本很少使用,我们可以在内部托管服务关闭的执行顺序;
- 在完成清理工作后可以执行exit调用,保证资源清理不会影响ShutdownHook的退出清理逻辑;
- JDK线程池策略是:核心线程-->阻塞队列--->最大线程--->丢弃策略。为什么不设置为:核心线程--->最大线程--->阻塞队列--->丢弃策略呢?
线程池系列(4)tomcat实现ThreadPoolExecutor和JDK线程池区别
两种线程池处理的任务类型不同。
JDK线程池处理的任务为CPU密集型任务,所以使用少量的线程来避免上下文切换。
Tomcat线程池重写了JDK线程池,实现了后者,后者处理的任务为IO密集型任务。当大量请求达到时,会开启最大线程进行处理,当后续请求变少时,只会销毁maximumPoolSize线程数(最大线程线程数)。
- JDK线程池如何调参来处理IO密集型的任务?
解决方案:可以提高核心线程数的大小。
但是存在副作用:会导致系统中活跃线程数一直过多。
解决方案:指定策略,当核心线程数空闲一段时间后,进行销毁;
但是存在副作用:低谷期核心线程数被销毁后,高峰期又会去创建核心线程,会使得接口响应时间变长。
- Tomcat是如何重写JDK线程池,使得适合IO密集型任务的?
Tomcat改造的是queue的offer(),即向queue放入任务时,若发现未达到最大线程数,那么offer()方法返回false,即放入队列失败,此时继续开启最大线程数的工作线程。
4. ThreadLocal相关
- ThreadLocal原理?
当执行线程执行ThreadLocal的set方法时,首先获取到当前线程所对应的ThreadLocalMap
,然后将ThreadLocal作为key,将set的值作为value,存储到ThreadLocalMap中。
而ThreadLocalMap底层只是数组实现的Map的结构,ThreadLocalMap并没有像HashMap一样采用哈希桶的方法来解决哈希冲突,而是采用线性探测法去解决Hash冲突。
- ThreadLocal为什么会造成内存泄漏?
ThreadLocal的key是弱引用,而value被强引用持有。而若不显示的调用remove方法,会造成内存泄漏。
- ThreadLocal针对内存泄漏的问题,有什么措施补救吗?
Entry对象有两个属性:一个是弱引用持有的key,一个是强引用持有的value。
当key(ThreadLocal本身)只被弱引用持有时。若发生了GC,key就会被回收。当然由于value不会被回收,所以可能会造成内存泄漏。
采用线性探测法解决哈希冲突的好处是:若没有显式的调用remove方法,在调用set或者get方法时,线性探测找位置时,也会清除key失效的节点。
- ThreadLocal为什么是static修饰
优点:使用了static,可以避免ThreadLocal对象被反复创建。
缺点:使用了static,ThreadLocalMap的key就会被强引用持有,触发显式调用remove()方法,否则key不会被回收。
利大于弊。
在Spring的bean中使用ThreadLocal,因为bean大多数是单例,故ThreadLocal有无static修饰效果相同。
- TTL是如何实现ThreadLocal跨线程数据传递?
从TransmittableThreadLocal使用前调研(源码分析)
其思想也是装饰器模式,去装饰线程池,装饰Runnable的run()方法。run方法执行前后,去读取和重置父线程设置的ThreadLocal的值。
5. volatile相关
- volatile如何保证的可见性
volatile实现了MESI协议,即缓存一致性协议。
JAVA内存模型分为工作内存和主内存。
volatile是一种非锁机制,来避免锁机制引发的上下文切换。解决的是:当一个线程写入该值后,另一个线程读取的必定是新值。
volatile保证了修饰的共享变量在转换成汇编语言时,会加一个以lock为前缀的指令,当CPU发现这个指令时,立即会做两件事:
- 将当前内核 中工作内存中的该变量刷新到主存;
- 通知其他内核里缓存的该共享变量内存地址失效;
于是就保证了共享变量的可见性;
- volatile为什么没有原子性
volatile保证了读写一致性,但是当线程2已经使用旧值完成运算指令,然后去覆盖主存里面的值,就无法保证原子性。
- volatile如何防止指令重排(如何实现内存屏障)
被volatile修饰的变量,会加一个lock为前缀的汇编指令。若变量被修改后,会将该变量由工作内存刷新到主存,那么意味着之前的操作已经执行完毕。这就是内存屏障。
6. concurrentHashMap相关
- ConcurrentHashMap实现原理?
ConcurrentHashMap在JDK1.7和JDK1.8实现方式是不同的。
JDK1.7:ConcurrentHashMap采用segment分段锁机制实现的。即ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成,即ConcurrentHashMap将哈希桶切分成小数组Segment,每个小数组有n个HashEntry组成。
默认情况下,ConcurrentHashMap被分为16个Segment。而去Map中添加新元素时,并不是对整个Map加锁,而是根据HashCode得到记录存储的segment,然后对segment加锁。
JDK1.8:在数据结构上,JDK1.8中ConcurrentHashMap选择了与HashMap相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃原有的Segment分段锁,采用CAS+synchronized实现更细粒度的锁。
锁的粒度控制在哈希桶级别,即只需要锁住链表的头节点,就不会影响其他哈希桶数组元素的读写。大大提高了并发度。
- HashMap的为什么线程不安全
- HashMap1.7当中,扩容的时候,采用的是头插法转移结点,在多线程并发的情况下会造成链表死循环的问题。
- HashMap的并发调用put方法时,可能造成值的覆盖;
- JDK1.8 concurrentHashMap为什么使用synchronized,而不是AQS?
- 获取JVM支持:synchronized是JVM支持的,在JDK1.8做出了相应的优化措施。性能和AQS相仿。synchronized可以在不升级JDK版本的情况下获取性能上的提升;
- 减少内存开销:假设使用AQS来获取同步支持,那么每个节点都需要继承AQS来获取同步支持,但并不是每一个节点都需要获得同步支持,只有链表的头结点需要同步,使用可重入锁会造成额外的内存开销;
- JDK1.7 和JDK1.8的ConcurrentHashMap的区别
- 数据结构:JDK1.7是Segment分段锁的数据结构,而JDK1.8是数组+链表+红黑树的结构。
- 线程安全机制:JDK1.7采用Segment分段锁机制实现线程安全,其中Segment继承与ReentrantLock。而JDK1.8采用的是CAS+synchronized保证的数据安全;
- 锁的粒度:JDK1.7对Segment加锁,JDK1.8锁粒度更小,对每个数组元素加锁;
- 链表转化红黑树:JDK1.7 Hash冲突时采用的是链表方式存储;JDK1.8会将链表转化为红黑树进行存储;
- 查询时间复杂度:JDK1.7遍历链表,时间复杂度O(n),JDK1.8遍历红黑树O(logN);
- ConcurrentHashMap的并发度(阿里面试题:分段锁的原理,锁力度减小的思考)?
并发度:程序运行时并行更新ConcurrentHashMap的最大线程数,在JDK1.7中,实际就是Segment分段锁个数,默认为16个。
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引发性能下降。
JDK1.8中,摒弃了Segment概念,选择了Node数组+链表+红黑树的结构,并发度依赖于数组的大小。
- ConcurrentHashMap的get方法需要加锁吗?为什么?
get方法不需要加锁,因为Node的元素value和指针next都是volatile修饰的,在多线程环境下保证了可见性。
- ConcurrentHashMap的put方法逻辑?
JDK1.7:先定位到Segment,然后进行put操作,因为JDK1.7锁粒度是Segment,首先会尝试获取锁,如果获取锁失败,则自旋获取锁;
JDK1.8的put方法:借助CAS+ synchronized锁对Node数组上第一个节点加锁。
- 阿里面试题:为什么Map桶中个数超过8才转为红黑树
Map中桶的元素初始化是链表保存的,其查找性能是O(n),而树结构能将查找性能提升到O(log(n))。当链表长度很小的时候,链表查询的速度也非常快,但是链表长度不断变长,肯定对查询性能有一定的影响,所以才会转换为树。但是树的插入和删除性能没有链表高。
树节点占用空间是链表节点的两倍,所以Map桶链表转树本质上是时间和空间的权衡。
在源码中这样描述,当hashcode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现不好的hash算法,因此就可能导致不均匀数据的分布。
不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以,之所以选择8,不是拍拍屁股决定的,而是根据概率统计决定的。由此可见,发展30年的Java每一项改动和优化都是非常严谨和科学的。
- HashMap为什么是头插法
因为HashMap作者发现,后插入的Entry被查找的可能性更大,所以放在头部。
- 【必看】HashMap在并发时会导致什么问题?
【面试库】--HashMap多线程put后get null ,get 死循环,get数据丢失(167)
- put操作同时扩容会造成死循环;
- put非null元素后get出来的却是null
【put操作引发扩容,另外的线程有可能get不到。例:线程1将table[j]=null;线程2同时去访问原table,结果违反了直觉。】
- 2个put操作会导致数据被覆盖。
- HashMap的负载因子的作用?
负载因子的作用就是节省时间和空间。负载因子的出现,虽然牺牲了一定的空间,但是在到达阈值(而非最大容量)的时候便进行扩容,避免出现大量的Hash冲突。
- HashMap负载因子为什么要设置为0.75,而不是1或者0.5?
负载因子为1.0时:意味着当HashMap底层数组全部填充后,才发生扩容,虽然空间利用率上来了,但再次存入的数据会导致Hash冲突,Hash冲突底层使用红黑树来进行填充。影响查询效率。
负载因子为0.5时:当数组中元素达到一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,底层的链表长度或红黑树的高度会降低,查询效率会增加。但是此时空间利用率会大大降低。
负载因子为0.75时:时间和空间的权衡,空间利用率比较高,而且可以避免相当多的Hash冲突。
7. 集合相关
- ArrayList在并发时会导致什么问题?
- add()操作抛出数组越界异常();
- add()操作会丢失元素;
- 线程A去写入/修改元素,线程B去遍历数组。那么线程B可能读取不到线程A写入的元素(因为线程A写入的数据并未刷新到主存中);
- 线程A去删除元素,线程B使用迭代器(forEach,底层是Iterator)遍历元素时,会出现
ConcurrentModificationException
异常,小概率会出现NPE异常。
- 几种线程安全的集合
- Vector/Collections.synchronizedList:读写均加锁,来实现线程安全;
- CopyOnWriteArrayList基于写时复制技术实现的,读操作无锁(读取快照),写操作有锁,体现了读写分离的思想,但是无法提供实时一致性。
8. JUC相关
- 常用juc包下面的类
- atomic相关类:使用CAS保证了原子性;
- lock相关类:包括AQS、ReentrantLock锁;
- BlockingQueue:阻塞队列相关类,内部使用lock实现的生产者消费者模型;
- ConcurrentMap:线程安全的Map对象,JDK7使用segment分段锁实现,JDK8使用CAS+ synchronized实现;
- 线程池相关类:包括ThreadPoolExecutor并发线程池和ForkJoinPool并行线程池;
- 辅助类:常用的例如
CyclicBarrier
循环栅栏、CountDownLatch计数器等。
9 场景题
- 线上有三个接口同时提供一个服务,三个接口性能不一,又快又慢。如何编写代码最快得到某个接口的结果呢?
- 线程池执行,使用Callable带有返回值的任务;
- 提交任务时,使用线程池的invokeAny方法,会返回最快的子任务的结果。
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(3);
Future<String> future = executorService.submit(() -> {
Thread.sleep(100);
return "A";
});
List<Callable<String>> ans = new ArrayList<>();
ans.add(() -> {
Thread.sleep(70);
return "A";
});
ans.add(() -> {
Thread.sleep(80);
return "B";
});
ans.add(() -> {
Thread.sleep(110);
return "C";
});
String s = executorService.invokeAny(ans);
System.out.println(s);
}
推荐阅读
Java并发基石——所谓“阻塞”:Object Monitor和AQS(1)
面试 ConcurrentHashMap,看这一篇就够了!
ConcurrentHashMap的computeIfAbsent源码分析
一文读懂JDK7,8,JD9的hashmap,hashtable,concurrenthashmap及他们的区别
相关阅读
JAVA并发(1)—java对象布局
JAVA并发(2)—PV机制与monitor(管程)机制
JAVA并发(3)—线程运行时发生GC,会回收ThreadLocal弱引用的key吗?
JAVA并发(4)— ThreadLocal源码角度分析是否真正能造成内存溢出!
JAVA并发(5)— 多线程顺序的打印出A,B,C(线程间的协作)
JAVA并发(6)— AQS源码解析(独占锁-加锁过程)
JAVA并发(7)—AQS源码解析(独占锁-解锁过程)
JAVA并发(8)—AQS公平锁为什么会比非公平锁效率低(源码分析)
JAVA并发(9)— 共享锁的获取与释放
JAVA并发(10)—interrupt唤醒挂起线程
JAVA并发(11)—AQS源码Condition阻塞和唤醒
JAVA并发(12)— Lock实现生产者消费者
JAVA并发(13)— ThreadPoolExecutor的实现原理(源码分析)