并发编程
- Synchronized原理是什么?
(1) Synchronized是由JVM实现的的一种实现互斥同步的一种方式,被Synchronized修饰过的代码块被编译前后被编译器生成了monitorenter和monitorexit两个字节码指令。当虚拟机执行到monitorenter指令时,首先要尝试获取对象的锁,如果这个对象没有锁,或者当前线程已经拥有了这个对象的锁,把锁的计数器+1;当执行monitorexit指令时将锁计数器-1;当计数器为0时,锁就被释放了。如果获取对象失败了,那么当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。Java中Synchronized通过在对象头设置标记,达到获取锁和释放锁的目的。
- 在上面提到的获取对象的锁,这个”锁”到底是什么?如何确定对象的锁?
(1) “锁”的本质其实monitorenter和monitorexit两个字节码指令的一个Reference类型的参数,即要锁定和解锁的对象。使用Synchronized可以修饰不同的对象,因此独享锁可以如下方法确定
① 如果Synchronized明确指定了锁对象,比如Synchronized(变量名)、Synchronized(this),说明加解锁对象为该对象
② 如果没有明确指定
1) 若Synchronized修饰的方法为非静态方法,表示此方法对应的对象为锁对象;
2) 若Synchronized修饰的方法为静态方法,表示此方法对应的对象为锁对象
3) 注意(重点):当一个对象被锁住时,对象里面所有用Synchronized修饰的方法都将产生堵塞,而对象中非Synchronized修饰的方法可正常被调用,不受锁影响
- JVM对java的原生锁做了哪些优化?
(1) 在java6之前Monitor的实现完全依赖底层操作系统的互斥锁来实现,也就是在上面2中提到的获取/释放锁的逻辑,由于java层面的线程于操作系统的原生线程有映射关系,如果将一个线程阻塞或者唤醒都需要操作系统来协助,这就需要从用户态切换到内核态来执行,这种切换代价十分昂贵,很耗处理时间,现代JDK中做了大量优化,一种优化是使用自旋锁,即在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这是就无需再让线程执行阻塞操作,避免了从用户态到内核态的切换。
(2) 现代JDK中还提供了三种不同的Monitor实现,也就是三种不同的锁
① 偏向锁
② 轻量级锁
③ 重量级锁
④ 这三种锁使得JDK得以优化Synchronized的运行,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这就是锁升级、降级
1) 当没有竞争出现时,会默认使用偏向锁。JVM会利用CAS操作,在对象头上的markword部分设置线程id,以表示这个对象偏向于当前线程,所以并不涉及正真的互斥锁,因为在很多应用场景中,大部分对象生命周期,最多会被一个线程锁定,使用偏向锁可以降低无竞争开销
2) 如果另一个线程试图锁定某个被偏斜过的对象,JVM就会撤销偏向锁,切换到轻量级锁实现。
3) 轻量级锁依赖CAS操作MarkWork来试图获取锁,如果重试成功,就是用普通的轻量级锁;否则进一步升级为重量锁
- 为什么说Synchronized是非公平锁?
(1) 非公平主要体现在获取锁的行为上,并非是按照申请锁的时间前后给等待的线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象。
- 什么是锁消除和锁粗化?
(1) 锁消除:指虚拟机即时编译器在运行时,对一些代码上要求同步,但被检查到不可能存在共享数据竞争的锁进行消除。主要四根据逃逸分析
(2) 锁粗化:原则上,同步块的作用范围要尽量小。但是如果一系列连续操作都对同一对象反复加锁和解锁,甚至加锁操作在循环体内,频繁的进行互斥同步操作也会导致不必要的性能损耗。锁粗化就是增大锁的作用域
- 为什么说Synchronized是一个悲观锁?乐观锁的实现原理又是什么?什么是CAS,它有什么特性?
(1) Synchronized显然是一个悲观锁,因为它的并发策略是悲观的:不管是否会产生竞争,任何数据操作都必须要加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。随着硬件指令集的发展,可以使用基于冲突检测的乐观并发策略。先进行操作,如果没有其他线程征用数据,那操作就成功了;如果共享数据有征用,产生了冲突,那就再进行其他的补偿措施。这种乐观的并发策略的许多实现不需要线程挂起,所以被称为非阻塞同步。乐观锁的核心算法是:CAS(Compareand Swap,比较并交换)。它涉及到三个操作数:内存值、预期值、新值。当且仅当预期值和内存值相等时才将内存之修改为新值。这样的处理逻辑是,首先检查某块内存的值是否跟之前读取时的值一样,如果不一样则表示期间此内存值已经被别的线程更改过,舍弃本次操作;否则说明期间没有其他线程对此内存值进行操作,可以把新值设置给此内存块。CAS具有原子性,它的原子性有CPU硬件指令实现保证,即使用JNI调用Native方法调用有C++编写的硬件级别指令,JDK中提供了Unsafe类执行这些操作
- 乐观锁就一定是好的吗?
(1) 优点:避免了悲观锁独占对象的现象,同时特提高了并发性能
(2) 缺点
① 乐观锁只能保证一个共享变量的原子操作。如果多于一个变量,乐观锁将显得力不从心,但互斥锁能够轻易解决,不管对象数量多少及对象颗粒度大小
② 长期自旋可能导致开销大,例如CAS长时间不成功而导致一直自旋,会给CPU带来很大的消耗
③ ABA问题。CAS的核心思想是是通过预期值和内存值是否一样而判断判断内存值是否被修改过,但是这个判断逻辑不严谨,加入内粗 值原来是A,后面被一条线程修改为B,最后又被改成了A,则CAS认为这个值并没有变化,但实际上是改变过的。这种情况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本+1.
- 与Synchronized相比,可重入锁ReentrantLock其实现原理有什么不同?
(1) Synchronized通过在对象头中设置标记实现了这一目的,是一种JVM原生的锁实现方式。而ReentrantLock以及所有基于Lock接口的实现类,都是通过volitile修饰的int型变量,并保证每个线程都对该int的可见性和原子修改,其本质是基于所谓的AQS框架。
- AQS框架是怎么回事?
(1) AQS(AbstractQueuedSynchronizer类)是一个用来构建锁和同步器的框架,各种Lock包中的锁(常用的有ReentrantLock、ReadWriteLock)以及其他如Semaphore、CountDownLatch,甚至是早期的FutureTask等,都是基于AQS来构建
(2) AQS在内部定义了一个volitile int state变量,表示同步状态:当线程调用lock方法时,如果state=0,说明没有任何线程占用共享资源的锁,可以获取锁并将state=1;如果state=1,说明有线程目前正在使用共享数据,其他线程必须加入同步队列进行等待
(3) AQS通过Node内部类构成一个双向链表结构的同步队列,来完成线程获取锁的排队过程,当有线程获取锁失败后,就被添加到队列末尾
① Node类是对要访问同步代码的线程的封装包含了线程本身及其状态waitStatus(有五种不同的取值,分别是是否被阻塞、是否等待唤醒、是否已经被取消等),每个Node的节点关联其prev节点和next节点,方便线程释放锁后快速唤醒下一个等待的线程,时FIFO的过程
② Node类有两个常量,SHARED和EXCLUSIVE,分别代表共享模式和独占模式。所谓共享模式时一个锁允许多条线程同时操作(信号量Semaphorephore就是基于AQS共享模式实现的),独占模式就是同一个时间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待
(4) AQS通过内部类ConditionObject构建等待队列(可以有多个),当Condition调用wait()方法后,线程将会加入等待队列中;当Condition调用signal()方法后,线程将从等待队列转移到同步队列中进行锁竞争
(5) AQS和Condition各自维护了不同的队列,在使用Lock和Condition的时候,就是两个队列的相互移动。
- 谈谈Synchronized和ReentrantLock的异同
(1) ReentrantLock是Lock的一个实现类,是一个互斥的同步锁,从功能角度,ReentrantLock比Synchronized的同步操作更精细,(因为可以向普通对象一样使用)甚至实现了Synchronized没有的高级功能,如
① 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以放弃等待,对执行处理时间很长的同步块很有用
② 带超时的获取锁尝试:在指定的时间范围内获取锁,如果时间到了仍然无法获取则返回
③ 可以判断是否有线程在排队等待获取锁
④ 可以响应中断请求:与Synchronized不同当获取到锁的线程被中断时,能够响应中断,中断异常将会抛出,同时锁会被释放
⑤ 可以实现公平锁。从锁释放角度,Synchronized是在JVM层面实现的,不但可以通过一些监控工具监控Synchronized的锁定,而且在代码执行出现异常时,JVM会自动释放锁定;但是使用Locke则不行,Lock时通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放在finally{}中;从性能角度,Synchronized早期实现比较低效,对比ReentrantLock,大多数场景性能都相差较大,但在java6中对其进行了非常多的改进,在竞争不激烈时,Synchronized的性能要优于ReentrantLock;在高竞争情况下,Synchronized的性能会下降几十倍,而ReentrantLock会维持常态。
- ReentrantLock是如何实现可重入性的?
(1) ReentrantLock内部定义了同步器Sync(Sync既实现了AQS,又实现了AOS,而AOS提供了一种互斥锁持有的方式),其实就是加锁的时候实现CAS算法,将对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程ID和当前请求的线程ID是否相同,一样就可重入
- 除了ReentrantLock,JUC中还有哪些并发工具 ?
(1) 通常所说的并发包(JUC)也就是java.util.concurrent及其子包,包括java并发的各种基础类,主要包括一下几个方面
① 提供了CountDownLatch、CircleBarrier、Semaphore等,比Synchronized更加高级,可以实现更加丰富的多线程操作的同步结构
② 提供了ConcurrentHashMap、有序的ConcurrentSkipListMap或者通过类似快照机制实现线程安全的动态数组CopyOnWriteArrayList等各种线程安全的容器
③ 提供了ArrayBlockingQueue、SynchorousQueue或针对特定场景的PriorityBlockingQueue等各种并发队列实现
④ 强大的Executor框架,可以创建各种不同类型的线程池,调度任务运行等
- Java中线程池是如何实现的?
(1) 在java中,所谓的线程池中的”线程”,其实是被抽象为了一个静态内部类worker,它基于AQS实现,在放在线程池的HashSet Workers成员变量中
(2) 需要执行的任务则存放在workQueue中,这样整个线程池的基本思想就是:从workQueue中不断取出需要执行的任务,放在Workers中进行处理
- 创建线程池的几个核心构造参数
(1) Java中的线程池的创建其实非常灵活,我们可以通过配置不同的参数,创建出行为不同的线程池,这几个参数包括:
① CorePoolSize:线程池的核心线程数
② MaximumPoolSize:线程池允许的最大线程数
③ KeepAliveTime:超过核心线程数时闲置线程的存活时间
④ WorkQueue:任务执行前保存任务的队列,保存由execute方法提交的Runnable任务
- 线程池中方线程时如何创建的?是一开始就随着线程池的启动创建好的吗?
(1) 显然不是的,线程池默认初始化后不启动worker,等待有请求时才启动,每当我们调用execute()方法添加一个任务时,线程池会做如下判断:
① 如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务
② 如果正在运行的线程数量大于或者等于corePoolSize,那么将这个任务放入队列
③ 如果队列满了,而且正在运行的线程数量小于MaximumPoolSize,那么还是要创建非核心线程立刻运行这个任务
④ 如果队列满了,而且正在运行的线程数量大于或等于MaximumPoolSize,那么线程池会抛出一个异常RejectExecutionExeception
⑤ 当一个线程完成任务时,它会从队列中取出下一个任务来执行,当一个线程无事可做,超过KeepAliveTime时,线程池会判断,如果当前运行的线程数大于corePoolSize,那么这个线程就会被停掉。所以线程池中是所有任务完成后,它最终会收缩到corePoolSize大小
- 既然提到可以通过配置不同的参数创建出不同的线程池,那么java中默认实现好的线程池又有哪些呢?他们之间有什么异同
(1) SingleTreadPool线程池:这个线程池只有一个核心线程在工作,也就相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程保证所有任务的执行顺序按照任务的执行顺序来执行
① CorePoolSize:1
② MaximumPoolSize:1
③ KeepAliveTime:0L
④ workQueue:new LinkedBlockingQueue<Runnable>,其缓冲队列是无界的
(2) FixedTreadPool线程池:是固定大小的线程池,只有核心线程。每次提交一个任务就创建一个线程,知道线程数达到线程池的最大值,线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。FixedTreadPool多数针对一些很稳定很固定的正规并发线程,多用于服务器
① CorePoolSize:n
② MaximumPoolSize:n
③ KeepAliveTime:0L
④ workQueue:new LinkedBlockingQueue<Runnable>,其缓冲队列是无界的
(3) CachedTreadPool线程池:无界线程池,如果线程池大小超过了处理任务所需要的线程,那么就会回收部分空闲的线程,当任务数增加时,又会智能的添加新线程来处理任务,线程池的大小完全依赖于操作系统能够创建的最大线程大小。SynchronousQueue是一个缓冲区为1的阻塞队列。经常用于执行一些生存期很短的异步型任务,因此在一个面向连接的daemon型SERVER中用的不多,但对于生存期短的异步任务,他是Executor的首选
① CorePoolSize:0
② MaximumPoolSize:Integer.MAX_VALUE
③ KeepAliveTime:60L
④ workQueue:new LinkedBlockingQueue<Runnable>,一个缓冲区为1的阻塞队列
(4) ScheduledTreadPool线程池:核心线程固定,大小无线的线程池。支持定时以及周期性执行任务的需求。创建一个执行周期性任务的线程池。如果闲置,非核心线程池会在DEFALUT_KEEPALIVEMILLIS时间内回收
① CorePoolSize:CorePoolSize
② MaximumPoolSize:Integer.MAX_VALUE
③ KeepAliveTime:DEFALUT_KEEPALIVEMILLIS
④ WorkQueue:new DelayedWorkQueue()
- 如何在java线程池中提交线程?
(1) Execute():ExecutorService.execute接受到一个例,它用来执行一个任务,代码:ExecutorService.execute(Runnable runnable)
(2) Submit():ExecutorService.submit方法返回的时future对象。可以用isDone()来查询future是否已经完成,当任务完成时,它具有一个结果,可以调用get()来获取。也可以不用isDone()检查直接调用get(),在这种情况下,get()将阻塞,直至结果准备就绪。
1什么是java内存模型,java中各个线程是怎么彼此看到对方的变量的?
(1) java内存模型定义了程序中国各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节,此处的变量包括实例字节、静态字段、构成数组对象的元素,但是不包括局部变量和方法参数,因为这些线程是私有的,不能被共享,所以不存在竞争问题
(2) Java中定义了主内存与工作内存的概念:所有变量都存储在主内存,每条线程还有自己的工作内存,保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存的变量。不同线程之间也无法访问对方工作内存中的变量,线程间变量的传递粗腰通过主内存
- 说说volatile有什么特点,为什么能保证变量对所有线程的可见性?
(1) 关键字volatile是Java虚拟机提供的最轻量级的同步机制。当一个变量被定义成volatile之后,具备两种特性:
① 保证此变量对所有线程的可见性。当一个线程修改了这个变量的值,新值对其他线程是可以立即得知的。而普通变量做不到这一点
② 禁止指令重排序优化。普通变量仅仅能保证在该方法执行过程中,得到正确结果,但是不保证程序代码的执行顺序
- volatile和Synchronized异同
(1) Synchronized既能保证可见性又能保证原子性,而volatile只能保证可见性,不能保证原子性
- ThreadLocal是如何解决并发安全的?
(1) ThreadLocal这是java提供的一种保存线程私有信息的机制,因为在其整个线程生命周期内有效,所以可以方便的在一个线程关联的不同的业务模块之间传递信息,比如事务ID、cookie等上下文相关信息
(2) ThreadLocal为每一个线程维护变量的副本,把共享数据的可见范围限制在同一个线程之内,其实现原理是:在ThreadLocal类中有一个Map,用于存储每一个线程的变量的副本。
- 对慎用ThreadLocal这个问题,谈谈看法
(1) 使用ThreadLocal要注意remove
(2) ThreadLocal的实现是基于一个所谓的ThreadLocalMap,在ThreadLocalMap中它的key是一个弱引用。通常弱引用都会和引用队列配置清理机制使用,但是ThreadLocal是个例外,这意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结束,进而回收响应ThreadLocalMap!这就死很多OOM的来源,所以通常都会建议,应用一定要自己负责remove并且不要和线程池配合,因为worker线程往往是不会退出的。
1、JMM(java内存模型)数据原子操作
(1) Read(读取):从主存读取数据
(2) Load(载入):将主内存数据读取到的数据写入工作内存
(3) Use(使用):从工作内存中读取数据来计算
(4) Assign(赋值):将计算好的值重新赋值到工作内存中
(5) Store(存储):将工作内存数据写入主内存
(6) Write(写入):将store过去的变量赋值给主内存中的变量
(7) Lock(锁定):将主内存变量加锁,标识为线程独占状态
(8) Onlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
(9)
2、缓存不一致问题
(1) 缓存一致性协议(MESI):多个cpu从主内存中读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里面的数据,该数据会马上同步会主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将将自己缓存里面的数据失效
(2) 缓存加锁:缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA32和Intel 64处理器使用MESI实现缓存一致性协议
3、Volatile可见性底层 实现原理
(1) Volatile缓存可见性原理实现
① 底层实现主要是通过汇编语言lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到内存
IA-32和Intel 64架构软件开发者手册对lock指令的解释
- 会将当前处理器缓存行的数据立即写会系统内存
- 这个写会内存的操作会引起其他cpu里缓存了该内存地址的数据无效(MESI)
- 提供内存屏障功能
4、指令重排序与内存屏障
(1) 并发编程三大特性:可见性、有序性、原子性
(2) Volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助Synchronized这样的锁机制
(3) 指令重排序:在不影响单线程执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化
①
(4) 重排序会遵从as-if-serial与happens-before原则
① As-if-serila语义:不管怎么重排序(编译器和处理器为了为了提高并行度),(单线程)程序执行的结果都不会发生改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据以来关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据以来关系,这些操作就可能被编译器和处理器重排序
② Happens-before原则:只靠Synchorized和volatile关键字来保证原子性、可见性和有序性,那么编写并发程序可能会显的十分麻烦,从JDK5开始,java使用新的JSR-133内存模型,提供了happens-before原则来辅助保证程序执行的原子性、可见性及有序性问题,他是判断数据是否存在竞争、线程是否安全的依据,happens-before原则内容如此啊:
1) 程序顺序原则:在一个线程内必须保证语义串行性,也就是按照代码顺序执行
2) 锁规则:解锁操作必然发生在后续的同一个锁的加锁之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后
3) Volatile规则:volatile变量的写,先发生于读,者保证了volatile变量的可见性,简单理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读取该变量的值,而当该变量发生变化时,有会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值
4) 线程启动规则:线程的start()方法先于它每一个动作,即如果线程A在执行线程B的start()方法之前修改了共享变量的值,那么当线程B执行start()方法时,线程A对共享变量的修改对线程B可见。
5) 传递性:A先于B,B先于C,那么A必然先于C
6) 线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join()方法返回后,线程B对共享变量的修改将对线程A可见
7) 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过Thread.interrupt()方法检测线程是否中断
8) 对象终结规则:对象的构造函数执行,结束先于finalize()方法
(5) 阿里面试题:双重检测锁DCL对象半初始化问题
①
5、对象创建的主要流程