JUC 并发编程
JUC并发编程
理论基础
线程和进程
进程
进程是程序的一次执行过程,是系统运行程序的基本单位,进程是动态的,操作系统运行一个程序即一个进程从创建、运行到消亡的过程。
在操作系统上,可以同时运行很多进程,每个进程之间相互隔离互不干扰,CPU通过时间片轮转算法,为每一个进程分配时间片,并在时间片使用结束后切换下一个进程继续执行,通过这种方式实现宏观上的多个线程同时运行。
每个进程都有自己独立的内存空间,并且进程之间的通信非常麻烦(例如共享某些数据),而且CPU执行不同进程会产生上下文切换,非常耗时。
线程
后来线程横空出世,一个进程可以有多个线程,线程是一个比进程更小的执行单位,线程是程序执行中一个单一的顺序控制流程,与进程不同的是,各个线程之间共享程序的内存空间即共享进程所在内存空间。在同一JVM进程中,同类的多个线程共享进程的堆和方法区资源,每个线程都有自己的程序计数器、虚拟机栈和本地方法栈。因此,系统中产生一个线程或是中各个线程之间切换工作时,负担要比进程小得多,上下文切换的速度也高于进程,所以线程也称为轻量级进程。
在Java中,当我们启动main函数时,其实就是启动一个JVM进程,而main函数所在的线程就是这个进程的一个线程,这个main函数所在的线程也称为主线程。
使用线程达到数组排序效果
1 | public static void main(String[] args) { |
使用JMX查看Java程序的线程
一个Java程序的运行是main线程和多个其他线程同时运行
1 | public static void main(String[] args) { |
JUC工具类
Java5新增了
java.util.concurrent
即JUC包,其中包含了大量用于多线程编程的工具类,目的是为了更好的支持高并发,减少在操作多线程时竞争条件和死锁的问题。
JMM内存模型
JMM内存模型和JVM中的内存模型不在同一层次上,JVM的内存模型是虚拟机规范对整个内存区域的规划,而Java内存模型是JVM内存模型之上的抽象模型,具体实现依然是基于JVM内存模型。
Java内存模型抽象
在Java中,所有实例域,静态域和数组元素存储在虚拟机堆内存中,堆内存在线程之间共享。(注意:局部变量,方法定义参数和异常处理器参数不存储在虚拟机堆内存中,线程之间不共享,他们不会有内存可见性问题,不受内存模型的影响)。
从抽象的角度来看,JMM定义了线程与主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。
Java线程之间的通信由Java内存模型(即JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序提供内存可见性保证。
线程不安全
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,操作的结果是不一致的。
1 | public class ThreadSafeExample { |
并发问题根源
并发三要素:
可见性-CPU缓存引起
,原子性-分时复用引起
,有序性-重排序引起
可见性:CPU缓存引起
可见性:一个线程对共享变量修改时,其他线程可以立即看到
当线程1修改一个共享变量时,会将这个共享变量的初始值从主内存加载到高速缓存中,然后修改赋值,此时高速缓存的发生了修改却没有立即刷新到主内存中,线程2从主内存中查看或者读取修改这个共享变量时,没有立即看到线程1修改的值,发生了可见性问题
原子性:分时复用引起
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
由于CPU分时服用即线程切换的存在,
有序性:重排序引起
线程基础
线程状态转换
新建(New)
创建后尚未启动
可运行(Runnbale)
可能正在运行或者正在等待CPU时间片(包含了操作系统线程状态中的Running和Ready)
阻塞(Blocking)
等待获取一个排它锁,如果其线程释放了锁就会结束此状态
限期等待(Timed Waiting)
无需等待其他线程显示地唤醒,在一定时间之后会被系统自动唤醒
进入方法 | 退出方法 |
---|---|
Thread.sleep()方法 | 时间结束 |
设置了Timeout参数的Object.wait()方法 | 时间结束/Object.notify()/Object.notifyAll() |
设置了 Timeout 参数的 Thread.join() 方法 | 时间结束/被调用的线程执行完毕 |
LockSupport.parkNanos()方法 | - |
LockSupport.parkUntil() 方法 | - |
调用Thread.sleep()方法使线程进入限制等待状态时即“使一个线程睡眠”
调用Object.wait()方法使线程进入限期等待或者无限制等待即“使一个线程挂起”
调用Thread.join()方法使线程无限等待阻塞当目标线程执行完毕即“使一个线程等待目标线程执行完毕”
无限期等待(Waiting)
等待其他线程显示地唤醒,否则不会被分配CPU时间片
进入方法 | 退出方法 |
---|---|
没有设置Timeout参数的Object.wait()方法 | Object.notify()/Object.notifyAll() |
没有设置Timeout参数的Thread.join()方法 | 被调用的线程执行完毕 |
LockSupport.park()方法 | - |
死亡(Terminated)
线程结束任务或者产生异常而结束
线程使用方式
Runnable接口和Callable接口类只能当作一个线程中运行的任务,不是真正意义上的线程,需要通过Thread调用,任务通过线程驱动从而执行的。
创建类继承Thread类重写run方法
1 | public class Thread1 extends Thread { |
通过Thread调用start()方法来启动线程
1 | public static void main(String[] args) { |
实现Runnable接口来实现run方法,通过Thread调用start()方法来启动线程
1 | public static void main(String[] args) { |
与Runnable相比,Callable可以有返回值,返回值通过
FutureTask
进行封装
1 | public static void main(String[] args) { |
实现接口和继承重写
实现接口更好一点
- Java不支持多重继承,继承Thread类就无法继承其它类,但可以实现多个接口
- 子类可能只要求可执行就好,继承整个Thread类的开销较大
基础线程机制
Executor
Executor管理多个异步任务的执行,无需程序显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作
主要的三种Executor:
- CachedThreadPool:一个任务创建一个线程;
- FixedThreadPool:所有任务只能使用固定大小的线程;
- SingleThreadExecutor:相当于大小为1的FixedThreadPool
1 | public static void main(String[] args) { |
Deamon
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
当所有非守护线程结束时,程序也会终止,同时杀死所有守护线程。
main()属于非守护线程,使用setDaemon()方法将一个线程设置为守护线程。
1 | public static void main(String[] args) { |
sleep()
Thread.sleep(millisec)方法会休眠当前正在执行线程。
sleep()可能会抛出InterruptedException,因为异常不能跨线程传播回main()中,因此必须在本地进行处理。
线程的其他异常也同样需要在本地进行处理。
1 | public static void main(String[] args) { |
yield()
对静态方法Thread.yield()的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换其他线程来执行。
该方法只是对线程调度器的一个建议,只是建议具有相同优先级的其他线程可以运行。
1 | public static void main(String[] args) { |
线程中断
一个线程执行完毕后回自动结束,如果在运行过程中发生异常也会提前结束。
Interrupt()
通过调用一个线程的interrupt()来中断该线程,当该线程处于阻塞、无限阻塞或者无限期等待状态,就会抛出
InterruptedException
,从而提前结束该线程。但是不能中断I/O阻塞和synchronized锁阻塞。
1 | public static void main(String[] args) { |
当中断一个处于阻塞、限期阻塞或者无限期等待的状态的线程,将会抛出
InterruptedException
异常。
Interrupted()
如果一个线程的run()方法执行
1 | public static void main(String[] args) { |
Executor的中断操作
调用Executor的
shutdown()
方法会等待线程执行完毕后再关闭,如果调用shotdownNow()
方法,相当于调用每个线程的interrupt()方法
1 | public static void main(String[] args) { |
如果只想中断Executor中的一个线程,可以通过使用submit()方法来提交一个线程,它会返回一个Future<?>对象,通过调用该对象的cancel(true)方法可以中断线程。
1 | public static void main(String[] args) { |
线程互斥同步
Java提供了两种锁机制来控制多个线程对共享资源的互斥访问。
第一个是JVM实现的sychronized,第二个是JDK实现的ReentrantLock。
Sychronized
同步一个代码块
只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。
当使用ExecutorService执行了两个线程,由于调用的是同一个对象的同步代码块,因此两个线程会进行同步,当一个线程进入同步语句块,另一个线程必须等待。
当使用ExecutorService执行两个线程调用了不同对象的同步代码块,因此两个线程不需要同步,两个代码交叉执行。
1 | public class SychronizedBlockExample { |
同步一个方法
它和同步代码块一样,作用于同一对象
1 | public class SychronizedFuncExample { |
同步一个类
作用于整个类,两个线程调用同一个类的不同对象也会进行同步。
1 | public class SychronizedClazzExample { |
同步一个静态方法
静态方法作用于整个类
1 | public class SychronizedStaticFuncExample { |
ReentrantLock
ReentrantLock
是java.util.concurrent(J.U.C)包中锁。
1 | public class ReentrantLockExample { |
1 | public static void main(String[] args) { |
Sychronized和ReentrantLock比较
锁的实现
synchronized
是JVM实现的,而ReentrantLock
是JDK实现的
性能
Java对synchronized进行了很多优化,例如自旋锁,轻量级锁等,synchronized与ReentrantLock大致相同。
等待可中断
当持有锁的线程长期不释放锁时候,正在等待的线程选择放弃等待,改为处理其他事情
ReentrantLock可以中断,而synchronized不可以中断。
公平锁
公平锁是指多个线程在等待同一锁时,必须按照申请锁的时间顺序来依次获得锁。
synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
锁绑定多个条件
一个ReetrantLock可以同时绑定多个Condition对象
使用选择
除非需要使用RetraintLock的高级特性,否则优先使用sychronized.
因为synchronized是JVM实现的一种锁机制,JVM原生地支持它;而ReentrantLock不是所有的JDK版本都支持。在使用ReetrantLock需要在finally代码块中完成锁的释放,而由于JVM会确保锁的释放,无论在没目标代码执行完还是发生异常的情况下,使用sychronized不用担心没有释放锁造成死锁问题,
线程之间协作
多个线程一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。
join()
1 | public static void main(String[] args) { |
wait() notify() notifyAll()
调用wait()使得线程等待某个条件满足,线程在等待时被挂起,当其他线程的运行使得某个条件满足时,其他线程会调用notify()或者notifyAll()来唤醒挂起的线程。
wait()、notify()、notifyAll()都属于对象Object的一部分,但是不属于Thread。它们只能在同步方法或者同步控制块中使用,否则会在运行时抛出IllegalMonitorStateExeception。
使得wait()挂起期间,线程会释放锁。因为如果没有释放锁,其他线程就没有办法进入对象的同步方法或者同步控制块中,无法执行notify()或者notifyAll()来唤醒挂起的线程,造成死锁。
1 | public class WaitNotifyExample { |
wait()和sleep()的区别
- wait()是Object方法,而sleep()是Thread的静态方法
- wait()是释放锁(其他进程需要进入对象的同步方法或者同步代码块执行notify()或者notigyAll()操作唤挂起的线程,防止死锁),sleep()是不会释放锁
await() signal() signalAll()
JUC类库中提供了Condition类来实现线程之间的协调,可以在Condition上调用await()方法是线程等待,其他线程调用signal()和signalAll()方法来唤醒等待的线程。
相比于wait(),await可以指定等待的条件,更加灵活。
1 | public class AwaitSignalExample { |
并发和并行
顺序执行
同一时间只能处理一个任务,依次完成所有的任务
并发执行
同一时间只能处理一个任务,每个任务轮着做(时间片轮换),宏观上就是三个任务同时进行
并行执行
同一时间可以处理多个任务
锁
Synchronized
C程序代码中,我们利用操作系统提供的互斥锁实现同步块的互斥访问以及线程的阻塞以及唤醒等工作,Java在语法层面上提供了
Synchronized
关键字来实现互斥同步原语。
Synchronized使用
一把锁只能同时被一个线程获取,没有获得锁的线程只能阻塞等待。
每个实例都有对应的自己的一把锁,不同的实例之间互不影响(当锁对象是修饰类或者static方法时,所有对象公用同一把锁)。
Synchronized修饰的方法将在方法正常执行或者抛出异常,完成锁的释放。
对象锁
包括方法锁和同步代码块锁
类锁
Synchronized原理
深入JVM查看字节码,反编译查看monitor指令
Monitorenter
和Monitorexit
指令:Monitorenter
和Monitorexit
分别对应着添加锁和释放锁
每个对象都有一个对象监视器与之对应,任意线程对对象进行访问时,首先获得对象的监视器,如果获取失败,该线程就进入同步队列,线程进入BLOCKED状态,当对象的监视器占有者释放后,在同步队列中的线程有机会重新获得该监视器。
Monitorenter
执行monitorentor需要尝试获取锁,获取锁的过程就是获取对象监视器的所有权,一旦监视器被某个线程持有,其他线程将无法获得。(管程模型)。
对象监视器中有一个计数器,当计数器为0时,表示目前没有被获取即没有被锁,获取对象监视器所有权后,执行monitorentor指令后会将监视器中的计数器加1,表示该对象的监视器已经被占有,别的线程获取需要等待,如果某线程获取到了对象的监控器的所有权,重入这把锁,对象监视器中计数器会累加,并随着重入的次数会一直累加。
Monitorexit
执行Monitorexit释放对象监控器的所有权,释放就是将对象监视器中的计数器减1,如果减完计数器不是0,表示是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该对象监视器的所有权,即释放锁。
正常释放锁执行Monitorexit会执行goto执行跳转到return执行,当出现异常时,执行释放锁并处理异常。
Sychronized存储结构
Synchronized锁信息就存储在Java对象头上。 Java对象存储在堆内存中,每个对象内部有一部分空间用于存储对象头信息,而对象头的信息中,包含对象的锁信息,不同状态下,存储结构有所不用。
Synchronized锁的类型
在JavaSE1.6里Synchronied同步锁里,一共有四种状态:
无锁
、偏向锁
、轻量级锁
、重量级锁
,它会随着竞争情况逐渐升级,锁可以升级但是但不可以降级,目的是为了提供获取锁的和释放锁的效率。锁的膨胀方向:无锁->偏向锁->轻量级锁->重量级锁(此过程是不可逆的)
重量级锁
在JDK1.6之前即没有加入锁优化时,Synchronized一直是重量级锁,对象监视器依赖于底层操作系统的Lock实现,Java线程是映射到操作系统的原生线程上,切换成本较高。
每个对象有一个对应的对象监视器与之关联,在Java虚拟机中,对象监视器由ObjectMonitor实现。
1 | ObjectMonitor() { |
每个等待锁的线程都会封装成ObjectWaiter对象,当ObjectWaiter进入EntrySet,当线程获取到对象的对象监视器后进入The Owner区域并把将对象监视器中的owner变量设置为当前线程,同时将计数器count加1,如果The owner区域的线程调用wait()方法,会释放当前持有的对象监控器,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒。
如果当前线程执行完毕会释放对象监控器,并复位变量以便其他线程进入获取对象的对象监视器。
在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,并且挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了极大的影响和压力。
由于在很多情况下,共享数据的锁定状态只会持续很短的时间,完全没有必要将竞争的线程挂起再唤醒。在多处理器的环境下,完全可以让另一个没有获取到锁的线程自旋一会即执行一个忙循环,但不释放CPU资源,不断检测是否可以获取锁,由于单个线程的占用锁的时间比较短,循环的次数不会太多,可能很快就可以拿到锁并运行。
自旋锁
自旋锁在JDK1.4中就引入,当时是默认关闭的,在JDK1.6就默认开启,自旋转本质和阻塞并不相同,阻塞需要将其他线程挂起并唤醒,如果锁占用时间非常短的话,自旋锁的性能将非常好,但是如果锁占用的时间较长的话,其会带来更多的性能开销(因为线程自旋时,会占用CPU的时间片,如果锁占用时间太长的话,自旋的线程会白白消耗CPU资源)。因此自旋等待的次数必须是有一定限度的,如果自旋超过了限定的次数仍然没有成功获取到锁,就会采用重量级锁的机制去挂起线程。在JDK定义中自旋锁默认的自旋次数为10次,可以使用参数
--XX:PreBlockSpin
更改。
自适应自旋锁
在JDK1.6中引入了自适应自旋锁,自旋锁得到了优化,自旋的时间不再固定,而是由前一次在同一锁上的自旋时间以及锁的拥有者的状态来决定。避免了线程锁在线程自旋结束刚好释放的问题。如果在同一个锁对象上,自旋等待刚好成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。相反,如果对于某一个锁,自旋很少成功获取锁,获取这个锁时将可能省略掉自旋过程,直接使用轻量级锁,以避免浪费处理器资源。
轻量级锁
在JDK1.6后引入了轻量级锁,轻量级锁不是代替重量级锁,而是对在大多数同步代码块不会产生竞争的情况的一种优化,从而减少重量级锁对于线程阻塞带来的资源开销,提高并发性能。
理解轻量级锁的实现,需要了解HotSpot虚拟机中对象头的内存布局,在对象头(Object Header)中存在两部分,第一部分称为
Mark Word
,一般为32位或者64位,用于存储对象自身的运行时数据,包括HashCode、GC Age、锁标记位、是否为偏向锁等,这部分是实现轻量级锁和偏向锁的关键;另一部分称为Klass point
,用于存储指向方法区对象类型数据的指针;如果该对象是数组,还会有一个额外部分用于存储数据的长度。
轻量级锁加锁原理
在线程执行同步代码块的之前,首先会先检查对象
Mark WOrd
中锁对象的占用情况,如果没有被其他线程占用,锁标志位为01状态,JVM会先在当前线程所处的栈帧中创建一个名为Lock Record
的锁记录的空间,用于存储锁对象目前Mark Word
的拷贝(JVM将对象头中的Mark Word
拷贝到锁记录中,官方称为Displaced Mark Ward
)。
虚拟机使用CAS操作将对象的标记字段
Mark Word
拷贝到锁记录中,并且将Mark Word
更新为指向Lock Record
的指针。如果更新成功,线程就拥有对象的锁,并且对象的Mark Word
的锁标志位更新为00,即表示此对象处于轻量级锁定状态。
如果更新操作失败,JVM会检查当前对象的
Mark Word
中是否存在指向当前线程的栈帧的指针。如果有,说明该锁已经被该线程获取,可以直接调用;如果没有,则说明该锁被其他线程抢占。当有两条或以上的线程竞争同一锁,轻量级锁将膨胀为重量级锁,没有获取锁的线程将会被阻塞,此时,锁的标志位为10.Mark Word
z中存储的指向重量级锁的指针。
轻量级锁解锁时,会使用原子CAS操作将
Displaced Mark Word
替换回到对象头中,如果成功,则表示没有发生竞争关系,如果失败,标识当前锁存在竞争关系,轻量级锁就会膨胀成重量级锁。
偏向锁
针对很多不存在多线程竞争,而是总是由同一线程多次获取锁的情况,为了避免同一线程反复获取锁释放锁带来的不必要的性能开销和上下文切换。Java1.6对于Synchronized进行了优化,引入了偏向锁,当一个线程访问同同步块并获取锁时,将在对象头和线程所在栈帧中的锁记录里存储锁偏向线程ID。之后该线程进入和退出同步代码块时,不需要进行CAS操作来加锁和解锁,只需要检查一下对象头的
Mark Word
里是否存储这指向当前线程的偏向锁(如果成功,则表示线程已经获取到了锁)。当检查对象的Mark Word
里存储不是指向当前线程的偏向锁,偏向锁将膨胀为轻量级锁。偏向锁的使用一般是添加
-XX:+UseBiased
参数开启
如果对象通过调用
hashCode()
方法计算出对象的一致性哈希值,那该对象不支持偏向锁,因为hashcode需要被保存,偏向锁的Mark Word
结构无法保存hashcode,如果对象已经是偏向锁的状态,再调用hashCode()
方法,偏向锁会直接膨胀为重量级锁,hashcode存放中monitor的对象中。
偏向锁的撤销
偏向锁只有竞争出现才会释放锁,只有当其他线程尝试获取锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销需要达到全局安全点(即当前线程没有正在执行的字节码),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否处于活动状态。如果线程不处于活动状态,直接将锁的对象头设置为无锁状态;如果线程处于活动状态,JVM将遍历线程所在栈帧中的锁记录,栈帧中的锁记录和锁的对象头偏向于其他线程,要么标记对象不适合作为偏向锁并将锁进行膨胀。
各类锁的优缺点
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步块执行速度较长 |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 如线程始终得不到锁竞争的线程,使用自旋会消耗CPU性能 | 最求响应时间,同步块执行速度非常快 |
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块 |
锁消除和锁粗化
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步但被检测到不可能存在共享数据竞争的锁进行消除。
锁消除的主要判定依据来源于逃逸分析的数据支持。JVM判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,JVM会把它们当作栈上的数据对待,认为这些数据是线程独有的,不需要加同步,进行锁消除。
在Java API中很多方法加了同步的,那么JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁。
1 | public static String test(String s1, String s2, String s3) { |
举例:由于String是不可变类,对字符串的连续操作总是通过生成新的String对象来进行的,而在执行上诉代码块操作String数据类型时,Javac编译器会对String连接自动优化,在JDK1.5之前会使用StringBuffer对象的连续append()操作,但是在JDK1.5之后的版本,会转化为使用StringBuilder对象的连续append()操作。尽管StringBuilder不是安全同步的,但JVM判断上诉代码不会逃逸,则将该代码默认为线程独有资源,并不需要同步,所以执行消除操作。
锁粗化
原则上添加同步锁时,要尽可能将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用于域中才进行同步),使得需要同步的操作数尽可能变小,使得在存在锁竞争中,等待锁的线程尽早拿到锁。
1 | public static String test(String s1, String s2, String s3) { |
当如果存在连串的一系列操作对同一对象反复加锁和解锁,即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。
在上述的连续append()操作中属于这类情况,JVM检测这样一连串的操作都是同一个对象加锁,那么JVM会将加锁同步的范围扩展(粗化)到整个一系列操作的外部,使得一连串的append()操作只需加锁一次。
锁类
关键字
Synchronized
Volatile
final
原子类
并发容器
线程池
并发工具