java并发编程的艺术
2018-09-19 10:57:28 0 举报
AI智能生成
并发编程的艺术总结
作者其他创作
大纲/内容
第一章并发编程的挑战
1.1上下文切换
1.1.1多线程一定快吗
在多核的处理其中,1、有一个主线程和一个子线程并发执行(循环制定次数进行简单的输出),2、主线程中写两个for循环都执行制定次数输出,结果是在数据量比较小的时候并发执行和串行时间差不多或者并发执行慢,在循环次数达到千万级别或者更大时,并发执行会花费较少的时间,所以说并发程序要比串行程序花费时间少是在一定的条件下才会发生(并发量足够大),并发量不够的时候串行程序未必会慢。
1.1.2测试上下文切换次数和时长
使用vmstat 1 命令可以查看上下文切换次数,其中的参数CS(Constent Switch)表示上下文切换次数
1.1.3如何减少上下文切换
1、无锁并发(多线程竞争锁是,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。)
2、CAS算法(乐观锁的方式)java的Atomic包使用CAS算法来更新数据,而不需要加锁。
3、使用最少线程(避免创建不需要的线程,比如任务很少,但是创建了很多的线程来处理,这样会造成大量线程处于等待状态)
4、协程(在单线程里实现多任务的调度,并在单线程里维持多个任务之间的切换)
1.1.4减少上线文切换实战
可以进行查看dump文件,查出哪些地方进行了waiting(锁等待)
jsp 查看线程都有哪些
jstack 线程号
生成dump
查看dump
减少线程池数量
1.2死锁
线程1使用对象A的锁,同时在锁定代码快中使用对象B的锁,线程2使用对象B的锁,同时在锁定代码快中使用对象A的锁,同时启动两个线程 ,很快出现死锁现象
如何解决死锁问题
1、避免一个线程同时获取多个锁
2、避免一个线程在锁内同时占用多个资源,尽量保证每个锁对象只占用一个资源
3、尝试使用定时锁,使用Lock.tyLock(timeout)来代替使用内部锁机制
4、对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
1.3资源限制的挑战
什么是资源限制
指在并发编程时,程序的执行速度受限于计算机的硬件资源和软件资源
硬件资源
带宽
上传下载速度
硬盘读写速度
cpu处理速度
软件资源
数据库的连接
socket连接数量
资源限制引发的问题
资源受限的情况下并发代码依然会串行执行,并且可能会导致cpu利用率到100%,几个小时都无法完成任务,修改为单线程后,一个小时就完成任务
如何解决资源限制问题
集群(不同机器处理不同数据)
使用ODPS
Hadoop
自己搭建服务器集群
资源复用
数据库连接池
Socket连接复用
调用对方webservice接口获取数据时,只建立一个连接
在资源限制下的并发编程
根据不同资源限制,调整程序的并发度
下载文件时依赖带宽和硬盘读写速度
数据操作时有数据连接和线程数量
1.4本章小结
建议多使用JDK并发容器和工具类来解决并发问题
第二章java并发机制的底层实现原理
2.1volatile的应用
可见性
当一个线程修改一个共享变量时,另外一个线程恩能够够读到这个修改的值。如果volatile变量修饰符使用的恰当的话,它比synchronized的使用和执行成本更低,因为他不会引起线程上下文的切换和调度。
volatile定义
java语言规范第三版中对volatile的定义如下:java编程语言允许线程访问共享变量,为了确保共享变量能够被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。
volatile实现原理
被volatile 修饰的的变量在转变为汇编语言后后包含一个Lock前缀,Lock前缀的指令在多核处理下会引发两件事情
1、将当前处理缓存行的数据写会到系统内存
Lock#信号会使用独占共享内存(锁总线)或者缓存锁定的方式(缓存行锁定)进行修改内存区域数据。
2、这个协会内存的操作会使在其他cpu里缓存了该内存地址的数据无效
处理器使用嗅探其他处理器的缓存的数据和总线上的数据,自己内部缓存,系统内存,数据保持一致
cpu的术语定义
内存屏障
memory barries 是一组处理器指令,用于实现对内存操作的顺序限制。
缓冲行
cache line CPU 高速缓存中更可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代cpu需要执行几百次cpu命令。
原子操作
atomic operations 不可中断的一个活一系列操作
缓存行填充
cache line fill 当处理器识别到从内存中去读操作数是可缓存的,处理器读取整个高度缓存行到适当的缓存(L1,L2,L3的或者所有)
缓存命中
cache hit 如果进行高度缓存填充操作的内存位置依然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取。
写命中
write hit 当处理器将草组数写会到内存缓存的区域时,他首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写会到缓存,而不是写会到内存,这个操作被称作写命中
写缺失
write misses the cache 一个有效的缓存行被写入到不存在的内存区域
volatile的使用优化
追加字节到64字节,使头节点和尾部节点各自在一个缓存行中,修改时不会互相锁定。(Doug lea 大师)
不适合追加字节的场景
缓存行非64字节宽的处理器
共享变量不会被频繁的写
2.2synchronized的实现原理和应用
2.2.1java对象头
java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。
存储结构
2.2.2锁的升级和对比
锁状态
无锁状态
偏向锁状态
但多数情况下,锁不仅攒在多线程竞争,而且总是由同一线程对策获得,为了让线程获得锁的代价更低,而引入偏向锁。当一个线程访问同步块,并获得锁时,会在对象头和栈帧的锁记录里存储锁偏向的线程Id,以后该线程再进入和退出同步块时候,不要要进行CAS操作来加锁和解锁,只需要简单的测试一下对象头的Mark Word里是否存储着只想当前线程的偏向锁。若成功,则表示已经获得锁,若失败,则需要再次测试Mark Word 中更偏向锁的表示是否置成1(表示当前是偏向锁),若没有设置,则使用CAS竞争锁,若设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁撤销需要等待全局安全点(在这个时间点上没有正在执行的字节码)。他会首先暂停拥有偏向锁的线程,然后检查持有偏下行锁的线程是否存活,若线程不处于活动状态,则将对象头置为无锁状态;若存活,拥有偏向锁的栈会被执行,便利偏向对象的所记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么回复到无锁状态或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
偏向锁撤销流程图
偏向锁关闭
java 6.java 7默认启用,可以用-XX:BiasedLockingStartupDelay=0来关闭延迟。可以用-XX:UseBiasedLocking=false来关闭偏向锁,程序直接进入轻量级锁状态
轻量级锁状态
加锁
线程在执行同步块之前,JVM会先在当前线程的栈帧中刚创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记住中,官方称Displaced Mark Word 。然后线程尝试使用CAS将对象头中的Mark Work替换为指向锁记录的指针,若成功,当前线程获得锁,若失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word 替换会到对象头,若成功,表示没有竞争发生。若失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
轻量级锁及膨胀流程图
重量级锁状态
因为自旋会消耗cpu,为了避免无用的自旋(比如获得锁的线程别阻塞住),一旦锁升级为重量级,就不会再恢复到轻量级锁状态,当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞,当持有锁的线程释放锁后会唤醒这些线程,被唤醒的线程进行新的一轮夺锁之争。
锁状态之间转换只会升级不会降级(无锁,偏向锁,轻量级锁,重量级锁)
锁的优缺点对比
优点
偏向锁
加锁和解锁不需要额外的消耗,和执行非同步方法相比存在纳秒级的差距
轻量级锁
竞争不会被阻塞,提高了程序的响应速度
重量级锁
线程不使用自旋,不消耗cpu
缺点
偏向锁
若线程存在锁竞争,会带来额外的锁撤销的消耗
轻量级锁
若始终得不到锁竞争的线程,使用自旋会消耗cpu
重量级锁
线程阻塞,响应时间缓慢
使用场景
偏向锁
只有一个线程访问同步块的场景
轻量级锁
追求响应时间,同步块执行速度非常快
重量级锁
追求吞吐量,同步块执行时间较长
使用场景
对于普通同步方法,锁的当前实例对象
对于静态同步方法,锁是当前类的Class对象
对已同步方法块,锁是Synchronized括号里配置的对象
2.3原子操作的实现原理
原子定义
不能被进一步分割的最小粒子,<br>而原子操作意为“不可被中断的一个或一系列操作”
相关术语
缓存行
Cache line 缓存的最小单位
比较交换
Compare and Swap CAS操作需要输入两个数值,<br>一个旧值(期望操作前的值)和一个新值,<br>在操作期间先比较旧值有没有发生变化,<br>如果没有发生变化,才交换成新值,<br>发生了变化则不交换。
cpu流水线
CPU pipeline CPU流水线的工作方式就像工业生产上装配流水线,<br>在CPU中由5-6个不同功能的电路单元组成一条指令处理流水线,<br>然后将一条X86指令分为5-6步后再有这些电路单元分别执行,<br>这样就能实现在一个CPU时钟周期完成一条指令,<br>因此提高CPU的运算速度
内存顺序冲突
Memory order violation 内存顺序冲突一般是由假共享引起的,<br>假共享是指多个cpu同时修改一个缓存行的不同部分<br>而引起其中一个cpu的操作无效,<br>当出现这个内存顺序冲突时,<br>cpu必须清空流水线。
处理器如何实现原子操作
缓存行加锁
指内存区域如果被缓存在处理器的缓存行中,<br>并且在Lock操作期间被锁定,<br>那么当他执行锁操作写会到内存时,<br>处理器不在总线上声言Lock#信号,<br>而是修改内部的内存地址,<br>并允许他的缓存一致性机制来保证操作的原子性,<br>因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,<br>当其他处理器回写已被锁定的缓存行数据时,会是缓存行无效。
总线加锁
所谓总线锁就是使用处理器提供一个LOCK#信号,<br>当一个处理器在总线上输出此信号时,<br>其他处理器的请求将被阻塞住,<br>那么该处理器可以独占共享内存。
java如何实现原子操作
锁
锁机制保证了只有获得锁的线程才能操作锁定的内存区域。<br>jvm内部实现了很多中锁机制,有偏向锁、轻量级锁、和互斥锁。<br>有意思的是除了偏向锁,jvm实现锁的方式都用了循环CAS,<br>即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,<br>当他退出同步块的时候使用循环CAS释放锁。
循环CAS
jdk的并发包里提供的一些类来支持原子操作
AtomicBoolea
AtomicInteger
AtomicLong
CAS实现原子操作的三大问题
ABA问题
增加版本号来解决ABA问题,<br>jdk1.5开始Atomic包里提供了一个类AtomicStampedReference来解决ABA问题
循环时间长开销大
如果jvm能支持pause指令,<br>那么效率会有一定的提升
第一 :pause指令可以延迟刘淑香执行指令(de-pipeline),<br>是cpu不会消耗过多的执行资源,<br>延迟的时间取决于具体的实现版本,<br>在一些处理器上延迟时间是零。
第二 :pause指令可以避免在退出循环的时候<br>因内存顺序冲突(Memory order Viloation)<br>而引起cpu流水线被清空(cpu pipeline flush),<br>从而提高cpu的执行效率。多个处理器在使用共享内存时,<br>有特定的几个处理被延迟处理,<br>这样处理器使用共享内存的顺序就不会有冲突,<br>在退出时对同一个内存区域的数据不有以后冲突的操作情况,<br>这样就会避免清空处理器的cpu流水线指令
只能保证一个共享变量的原子操作
可以使用把多个共享变量合并为一个对象的方式<br>进行操作合并后的对象,<br>java1.5中AtomicReference类来保证对象之间的原子性<br>就是把多个变量放在一个对象里进行CAS操作的。
2.4本章小结
研究bolatile、synchronized、原子操作的实现原理<br>对于java大部分容器和框架都使用何有帮助。
第三章java内存模型
3.1java内存模型的基础
3.1.1并发模型中的两个关键问题
线程之间如何通信<br>(通信是指线程之间以何种机制来交换信息)
共享内存
在共享内存的并发模型里,<br>线程之间共享程序的公共状态,<br>通过写-读内存中的公共状态进行隐式通信。
消息传递
在消息传递的并发模型里,<br>线程之间没有公共状态,<br>线程之间必须通过发送消息显示进行通信
线程时间如何同步<br>(同步是指程序中用于控制不同线程间操作<br>发生的相对顺序的机制)
共享内存
同步是显示进行,<br>程序员必须显示指定某个方法<br>或者某段代码需要再线程之间<br>互斥执行。
消息传递
由于消息的发送必须在消息的接收之前<br>因此同步时隐式进行的。
java采用的是共享内存模型,<br>java线程时间的通信总是隐式进行的,<br>整个通信过程对于程序员完全透明。<br>
3.1.2java内存模型的抽象结构
共享变量(堆内存中)
java实例域
静态域
数组元素
非共享变量(线程独享)
局部变量
方法定义参数
异常处理器参数
线程和主存之间的抽象关系
线程之间的共享变量存储在主内存中(Main Memory),<br>每个线程都有一个私有的本地内存(Local Memory),<br>本地内存中存储了该线程以读/写共享变量的副本。<br>本地内存是JMM的一个抽象概念,并不真实存在。<br>他涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
JMM内存模型抽象结构示意图
JMM
java线程之间的通信有java内存模型(JMM)控制,<br>JMM决定一个线程对共享变量的写入何时对另一个线程可见
JMM通过控制主内存和每个线程的本地内存之间的交互,<br>来为程序员提供内存可见性的保证。
线程A和线程B通信
步骤一:<br>线程A把本地内存A中更新过的共享变量刷新到主内存中去。
步骤二:<br>线程B到主内存中去读取线程A之前一斤刚更新过的共享变量
AB线程通信图
线程A修改,线程B读取共享变量
3.1.3从源代码到指令的重个排序<br>(编译器和处理器常常对指令进行重排序,分为三种)
1、编译器优化重排序,<br>编译器在不改变单线程程序语义的前提下,<br>可以重新安排语句的执行顺序。
2、指令级并行的重排序<br>指令集并行技术(Instruction-Level-Parallelism,ILP),<br>将多条指令重叠执行。若不存在数据依赖性,<br>处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序<br>由于处理器使用缓存和读写缓冲区,<br>这使得加载和存储操作看上去可能在乱序执行
源代码到最终执行的指令序列示意图
3.1.4并发编程模型的分类
处理器使用写缓冲区临时保存向内存写入的数据,<br>写缓冲区可以保证指令流水线持续运行,<br>他可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。<br>同时,通过以批处理的方式刷新写缓存,<br>以及合并写缓冲区中对同一个内存地址的多次写,<br>较少对内存总线的占用。<br>但由于每个处理器上的写缓冲区,仅对他所在的处理器可见。<br>他会对内存操作的执行顺序产生重要的影响:<br>处理器对内存的读写操作的执行顺序,不一定域内存实际发生的读写操作顺序一致。
线程A:a=1; // A1 x=b;//A2<br>线程B:b=2; //B1 y=a;//B2<br>初始状态:a=b=0;<br>处理器允许执行后的结果为:x=y=0
处理器和内存的交互
处理器的重排序规则
常见的处理器都允许Store-Load重排序<br>常见的处理器都不允许对存在数据依赖的操作做重排序<br>sparc-TSO(Total Store Order)和X86拥有相对较强的内存模型,它们仅允许写读操作的重排序,<br>(因为他们都使用了写缓冲区)
<b>内存屏障的分类</b>(为了保证内存可见性,<br>java编译器在生成至指令序列的适当位置<br>会插入内存屏障指令<br>来禁止特定类型的额处理器重排序)
LoadLoad Barriers
Load1;LoadLoad;Load2<br>确保Load1数据的装载先于Load2<br>及所有后续装载指令的装载
StoreStore Barriers
Store1;StoreStore;Store2<br>确保Store1数据对其他处理器可见<br>(刷新到内存)先于Store2<br>及后续所有存储指令的存储
LoadStore Barriers
Load1;LoadStore;Store2<br>确保Load1数据装载先于Store2<br>及所有后续存储指令刷新到内存
StoreLoad Barriers
Store1;StoreLoad;Load2<br>确保Store1数据对其他处理器变得可见<br>(指刷新到内存)<br>先于Load2及后续装载指令的装载,<br>StoreLoad Barriers 会使该屏障之前的所有内存访问指令<br>(存储和装载指令)完成后,才执行该屏障之后的内存访问指令
是一个“全能型”的屏障,<br>他同时具有其他三个屏障的效果,<br>(处理器大多支持该屏障,<br>其他类型的屏障不一定被所有处理器支持)<br>该屏障开销会很昂贵,<br>因为当前处理器通常要把写缓冲区中国的数据全部刷新到内存中<br>(Buffer Fully Flush)
3.1.5happens-before简介
内容
从jdk5开始,java使用新的JSR-133内存模型(除非特别说明,<br>后续都是针对JSR-133内存模型),JSR-133使用happens-before的概念<br>来阐述操作之间的内存可见性。<br>在JMM中,如果一个操作执行的结果需要对另个一操作可见,<br>那么这两个操作之间必须要存在happens-before关系。<br>这里提到的两个操作,既可以在一个线程内,也可以在不同的线程之间。
规则
程序顺序规则:<br>一个线程的每个操作,happens-before于该线程中的任意后续操作
监视器锁规则:<br>对于一个锁的解锁,happens-before于随后对这个锁的加锁<br>
volatile变量规则:<br>对于一个volatile域的写,happens-before于任意后续对这个volatile域的读
传递性:<br>如果A happens-before B,<br>且B happens-before C,<br>那么A happens-before C
注意:
两个操作之间具有happens-before关系,<br>并不意味着前一个操作必须要在后一个操作之前执行!<br>happens-before仅仅要求前一个操作(执行结果)对后一个操作可见,<br>且前一个操作按顺序排在第二个操作之前(the first vivisible to and ordered before the second)。<br>
<b><font color="#c41230">happens-before与JMM关系图:<br>一个happens-before规则对应于一个或者多个编译器和处理器重排序规则</font></b>
3.2重排序
3.2.1数据依赖性
如果两个操作访问同一个变量,<br>且这两个操作中有一个是 写操作,<br>此时这两个操作之间就存在数据依赖性
<b>写后读:</b><br>a=1;<br>b=a:<br>写一个变量之后,<br>再次读这个变量
<b>写后写:</b><br>a=1;<br>a=2;<br>写一个变量之后,<br>再写这个变量
<b>读后写:</b><br>a=b;<br>b=1;<br>读一个变量之后,<br>再写这个变量
只要排序任意连个操作的顺序<br>程序执行结果就会发生变化
注意:<br>数据依赖性针对单个处理器中执行的指令序列<br>和单个线程中执行的操作,<br>不同处理器之间和不同线程之间的数据依赖<br>不被编译器和处理器考虑
3.2.2as-if-serial
语义:<br>不管怎么重排序(编译器和处理器为了提高并行度),<br>(单线程)程序的执行结果不能被改变。<br>编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,<br>编译器和处理器不会对存在数据依赖关系的操作重排序,<br>因为这种重排序会改变执行结果。<br>但是,若操作之间不存在数据依赖关系,<br>这些操作就可能被编译器和处理器重排序。
double pi=3.14;//A<br>double r=1.0;//B<br>double area=pi*r*r;//C<br>按照程序顺序执行结果are=3.14;<br>a->b->c;<br>重排序后的执行结果:are=3.14;<br>b->a->c;
as-if-serial语义吧单线程程序保护了起来,<br>遵守as-if-serial语义的编译器、runtime<br>和处理器共同为编写单线程程序的程序员创建了一个幻觉:<br>单线程程序是按照程序的顺序来执行的。<br>as-if-serial语义使单线程程序员无需担心重排序会干扰他们,<br>也无需担心内存可见性问题。
3.2.3程序重排序规则
在计算机中,软件技术和硬件技术有一个共同的目标:<br>在不改变程序执行结果的前提下,<br>尽可能提高并行度。编译器和处理器遵从这一目标,<br>从happens-before的的定义我们可以看出,JMM同样遵从这一目标。
定义:<br>重排序是指编译器和处理器为了优化程序性能<br>而对指令序列进行重排序的一种手段
3.2.4重排序对多线程的影响
在单线程程序中,对存在控制依赖的操作重排序,<br>不会改变执行结果(这是as-if-serial语义允许对<br>存在控制依赖的操作做重排序的原因);但在多线程<br>程序中,对存在控制依赖的操作重排序,可能会改变<br>程序的执行结果。<br><br>
3.3顺序一致性
顺序一致性内存模型是一个理论参考模型,<br><br>在设计的时候,处理器的内存模型和<br><br>编程语言的内存模型都会以顺序一致性内存模型作为参照
3.3.1数据竞争与顺序一致性
数据竞争
java内存模型规范对数据竞争的定义:<br>在一个线程中写一个变量,<br>在另一个线程读这个变量,<br>而且写和读没有通过同步来排序。<br>
当代码中包含数据竞争时,<br>程序的执行往往产生违反直觉的结果,<br>。若一个多线程程序能够正确同步,<br>这个程序将是一个没有数据竞争的程序。
顺序一致性
JMM对正确同步的多线程程序的内存一致性做了如下保证:<br>若程序是正确同步的,<br>程序的执行将具有顺序一致性(Sequentially Consistent)<br>-------即程序的执行结果与该程序的顺序一致性内存模型中的<br>执行结果相同。这里的同步是广义上的同步,包括对常用同步原语<br>(synchronized、volatile和final)的正确使用。
3.3.2顺序一致性内存模型
顺序一致性的两个特性
第一:<br>顺序执行
第二:<br>所有线程看到一个操作执行顺序
所有线程串行化
使用监视器锁来正确同步
没有做同步
3.3.3同步程序的顺序一致性效果
3.3.4未同步程序的执行特性
3.4volatile的内存语义
3.4.1volatile的特性
3.4.2volatile写-读建立的happens-before关系
3.4.3volatile写-读的内存语义
3.4.4volatile内存语义的实现
3.4.5JSR-133为什么要增强volatile的内存语义
3.5锁的内存语义
3.5.1锁的释放-获取建立的happens-before关系
3.5.2锁的释放和获取的内存语义
3.5.3锁内存语义的实现
3.5.4concurrent包的实现
3.6final的内存语义
3.6.1final域的重排序规则
3.6.2写final域的重排序规则
3.6.3读final域的重排序规则
3.6.4final域为引用类型
3.6.5为什么final域引用不能从构造函数中“溢出”
3.6.6final语义在处理器中的实现
3.6.7JSR-133为什么要增强final语义
3.7happens-before
3.7.1JMM的设计
3.7.2happens-before的定义
3.7.3happens-before规则
3.8双重检查锁定与延迟初始化
3.8.1双重检查锁定的由来
3.8.2问题的根源
3.8.3基于volatile的解决方案
3.8.4基于类初始化的解决方案
3.9java内存模型综述
3.9.1处理器的内存模型
3.9.2各种模型之间的关系
3.9.3JMM的内存可见性保证
3.9.4JSR-133对旧内存模型的修补
3.10本章小结
第四章java并发编程的基础
第五章java中的锁
第六章java并发容器和框架
第七章java中的13个原子操作类
第八章java中的并发工具类
第九章java中的线程池
第十章Executor框架
第十一章java并发编程实战
0 条评论
下一页