并发:指多线程交替执行
并行:指同时执行
1、上下文切换
上下文切换是一个过程,任务从被CPU保存到再加载的过程就是一次上下文切换。上下文的切换会影响多线程的执行速度。
并不是启动更多的线程就能让程序最大限度的并发执行,多线程会面临创建线程和上下文的切换的开销,死锁问题。
CPU通过给每个线程分配时间片,即线程的执行时间,通常非常短,一般是几十毫秒,因此CPU通过不停的切换线程执行。在切换前会保留上一个任务的状态(保存在进程控制块(PCB,CPU的内存中)),以便下次切换回在这个任务时可以加载这个任务的状态。
(上下文切换像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码(书签))
如何减少上下文切换?
减少上下文的切换有无锁并发编程、CAS算法、使用最小线程和使用协程。
1)无锁并发编程:多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以使用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
2)CAS:如Java Atomic包下的API都是根据CAS来更新修改数据,不需要加锁
3)使用最小线程:避免创建不需要的线程,任务很少却创建很多线程会造成大量线程出于等待状态
4)在单线程上实现多任务的调度,并在单线程里维持多个任务间的切换
2、Java并发机制底层实现
Java代码在编译后生成字节码,字节码被类加载器加载到JVM中,JVM执行字节码最终转化为汇编指令在CPU上执行。Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
1)volatile
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的 “可见性”。volatile使用恰当的话比synchronized的使用和执行成本低,因为它的使用不会引起线程上下文的切换和调度。
2)synchronized
JDK 1.6对synchronized进行了大量优化,使其不那么重了。如为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
Java中的每一个对象都可以作为锁,有以下三种形式:
(1):对于普通方法,锁是当前实例对象
(2):对于静态同步方法,锁是当前类的Class对象
(3):对于同步代码块,锁是synchronized括号里的对象
当一个线程试图访问同步代码块时,它必须先得到锁,退出或抛出异常时必须释放锁。那么锁存放在哪里呢?锁里面会存储什么信息呢?
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,代码块同步是通过monitorenter和monitorexit指令实现的。monitorenter指令是在
编译后插入到同步代码块的开始位置,而monitorexit是插入到方法的结束处和异常处,JVM要求每个monitorenter必须有对应的monitorexit与之配对
。每个对象都有一个monitor与之关联,是每个对象与生俱来的隐藏字段,线程获取锁时,会根据锁对象头中的monitor状态进行加锁判断,如果
monitor为0,就可以加锁持有锁,并将monitor置为1,如果当前线程已经持有了monitor,那么monitor继续加1(可重入),线程执行完毕释放锁则
将monitor减1,直至monitor为0,则锁释放成功;如果monitor为1,表示锁已经被其他线程持有,则线程将处于BLOCKED阻塞状态。
3)Java对象头
synchronized锁是存在Java对象头中的,如果对象是数组类型,JVM使用3个Word(自宽)存储对象头,如果对象是非数组类型,则用2 Word存储对象头。
在32位虚拟机中,1 Word等于4字节,即32bit。JVM中对象头的布局分为两部分信息:
(1)第一部分用来存储对象自身的运行时数据,如哈希码(hashCode)、GC分代年龄、锁标志位等。官方称为Mark Word,它是实现轻量级锁和偏向锁的关键。
(2)另外一部分用于存储指向方法区对象类型的指针,如果是数组的话,还会有一个额外的部分用于存储数组的长度。
32位JVM中的Mark Word默认存储结构和不同锁下的状态变化为:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁,0否1是 | 2bit锁标志位 | |
23bit | 2bit | ||||
无锁 | 对象的hashCode | 对象分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量的指针 | 10 | |||
GC标志 | 空 | 11 |
4)锁优化
JDK1.6中锁一共有4种状态,级别从低到高依次是无锁、偏向锁、轻量级锁、重量级锁;锁的状态会随着竞争逐渐升级,而不能降级。
(1)偏向锁:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程获得,为了让线程获得锁的代价更低所以引入了偏向锁。
偏向锁的“偏”,就是偏心的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏
向锁的线程将永远不需要再进行同步。
当锁对象第一次被线程获取的时候,虚拟机会把锁对象头中的标志为设为01,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的 Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。当有另外一个线
程去尝试获取这个锁时,偏向模式宣告结束。
根据锁对象目前是否出于被锁定的状态,撤销偏向后恢复到无锁状态(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就按轻量级
锁的过程来执行。
偏向锁可以提高带有同步但无竞争的程序性能。但是它并不一定总是对程序有利,如果程序中大多数的锁总是被多个不同的线程访问,那么偏向模式就是
多余的。可通过JVM参数 -XX:UseBiasedLocking=false 来关闭偏向锁(默认是开启的)
(2)轻量级锁
加锁:线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。
然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获得锁。
解锁:使用原子的CAS操作将Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
三种锁对比:
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 |
加锁和解锁不需要额外的消耗,和执行 非同步方法相比仅存在纳秒级的差距 |
如果线程间存在锁竞争,会带来 额外的锁撤销的消耗 |
只有一个线程访问同步块 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 |
如果始终得不到锁竞争的线程, 使用自旋会消耗CPU |
追求响应时间 同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行时间较长 |
5)原子操作实现原理
原子(atomic)本意是“不能被分割的最小粒子”。原子操作意为:不可被中断的一个或一系列操作。
Java中通过锁和自旋CAS的方式来实现原子操作。
(1):使用自旋CAS实现原子操作
JVM中的CAS操作利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。
(2):CAS实现原子操作的三大问题
1、ABA问题,解决思路就是使用版本号,在变量前面追加版本号,每次更新的时候把版本号加1,那么ABA就会变成1A2B3A。JDK的Atomic包里提供了AtomicStampedReference
来解决ABA问题,它的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志(版本号),如果全部相等,则以原子方式将
该引用和该标志的值设置为给定的更新值。
2、循环时间长开销大,如果自旋CAS长时间不成功,会给CPU带来非常大的执行开销。
3、一次只能保证一个共享变量的原子操作,当对一个共享变量执行操作时,我们可以使用自旋CAS的方式来保证原子操作,但是对多个共享变量操作时,自旋CAS就无法保证操作
的原子性,这个时候可以用锁。JDK1.5提供了AtomicReference来保证引用对象之间的原子性,我们可以将多个变量放在一个对象里来进行CAS操作。
(3):使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能操作锁定的内存区域,因此不会被打断,实现原子操作。
end