并发编程
2020-03-06 17:04:30 0 举报
AI智能生成
并发编程知识点
作者其他创作
大纲/内容
并发编程
补充知识
硬件问题
问题:在硬件发展中,CPU、内存、I/O设备的速度始终存在差异,CPU >> 内存 >> I/O设备
优化:
1.CPU增加了缓存,以均衡内存与CPU的速度差异
2.操作系统增加了进程、线程,以分时复用CPU,进而均衡I/O设备与CPU的速度差异
3.编译程序优化指令执行次序,是的缓存能够得到更加合理的利用
Java对象头
对象在内存中存储的布局可分为三块:对象头(Header)、实例数据(InstanceData)和对齐填充(Padding)
普通对象的对象头包括两部分:Mark Word 和 Class Metadata Address(类型指针),如果是数组对象还包括一个额外的Array length数组长度
Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,占用内存大小与虚拟机位长一致。
Class Metadata Address:类型指针只想对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。
Mark Word
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,MarkWord被设计成一个非固定的数据结构以便在极小的空间内存储经量多的信息,它会根据对象的状态复用自己的存储空间
并发理论
三大问题
可见性
定义:一个线程对共享变量的改变,另外一个线程能够立刻看到
原因:缓存不能及时刷新(重排序)导致了可见性问题
解释:CPU都有自己的缓存,以缓冲的方式将CPU中数据的变化刷新到主内存中,势必会在两个线程并发执行时,不能得到对方线程对数据的改变,而最终CPU将错误的结果刷新到主内存
原子性
定义:一个或者多个操作在CPU执行的过程中不被中断的特性(区别于事务)
原因:由于线程的切换,导致多个线程同时执行同一段代码,带来的原子性问题
解释:代码的执行并非一定是原子操作。如:int a=0,是原子操作;int a=b,则包含读取b的值,赋值给a;a++,则分为取值a,a+1,将结果赋值给a。
有序性
定义:程序执行的顺序按照代码的先后顺序执行
原因:编译器为了优化性能而改变程序中语句的先后顺序(重排序),导致有序性问题
举例:双重检查机制创建单例对象,是否安全?当执行instance = new Singleton();时,实际分为三个步骤
1.分配内存空间;2.初始化对象Singleton;3.将内存空间的地址赋值给instance。
经过重排序后可能为:
1.分配内存空间;2.将内存空间的地址赋值给instance;3.初始化对象Singleton。
当线程A执行到指令2时恰好切换线程B执行同样代码,将会认为instance不为空而返回instance,但此时还未给instance初始化。导致NullPointException
重排序
概念
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序
Java源代码 ---> 1编译器优化的重排序 --->2指令级并行的重排序 --->3内存系统的重排序 --->最终执行指令
三种重排序
编译器优化重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
指令级并行重排序
处理器将多条指令重叠执行
内存系统的重排序
处理器使用缓存和读/写缓冲区,是的加载和存储操作看上去可能是在乱序执行
规则
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
存在数据依赖关系的两个操作,不可以重排序
as-if-serial语义
不管怎么重排序,单线程中程序的执行结果不能被改变
问题
可见性问题
有序性问题
解决重排序的问题
Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序
JMM根据代码中的关键字(如:synchronized\\volatile)和JUC包下的一些具体类来插入内存屏障
内存模型JMM
结构
主内存
存储共享变量
堆内存在线程之间共享,存储在堆内存中所有实例域、静态域和数组元素都是共享变量
本地内存
存储该线程用以读/写共享变量的副本
解决可见性和有序性问题
happens-before规则
定义:一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-befor关系
原则
1. 一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生与书写在后面的操作;
2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
3. volatile变量规则:对一个变量的写操作先行发生与后面对这个变量的读操作;
4. 传递规则: 如果操作A先行发生与操作B,而操作B又先行发生与操作C,则可以得出操作A先行发生与操作C;
5. 线程启动规则:Thread对象的start(0方法先行发生与此线程的每一个动作;
6. 线程终端规则:对线程interrupt()方法的调用先行发生与被中断线程的代码检测到中断事件的发生;
7. 现在终结规则:线程中所有的操作都先行发生与线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
8. 对象终结规则:一个对象的初始化完成先行发生与它的finalize()方法的开始;
并发关键字
volatile
保证可见性
对一个volatile变量的读,任意线程总能看到对这个volatile变量最后的写入。
1.一个线程修改volatile变量的值时,该变量的新值会立即刷新到主内存中,这个心值对其他线程来说是立即可见的。
2.一个线程读取volatile变量的值时,该变量在本地内存中缓存无效,需要到主内存中读取。
原理
volatile内存屏障插入策略中有一条,“在每个volatile写操作的后面插入一个StoreLoad屏障”。
该操作会导致
1. 将当前处理器缓存的数据写回到主内存
2. 这个写回主内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
volatile内存可见的写-读过程:
1. volatile修饰的变量进行写操作。
2. 由于编译期间JMM插入了一个StoreLoad内存屏障,JVM就会想处理器发送一条Lock前缀的指令。
3. Lock前缀的指令将该变量所在缓存行的数据协会到主内存中,并使其他处理器中缓存了该变量内存地址的数据失效。
4. 当其他线程读取volatile修饰的变量时,本地内存中的缓存失效,就会到主内存中读取最新的数据。
保证有序性
volatile关键字能禁止指令重排序,保证了程序会严格按照代码的先后顺序执行,即保证了有序性。
volatile禁止重排序规则:
1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。确保volatile写之前的操作不会被编译器重排序到volatile写之后
2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。确保volatile读之后的操作不会被编译器重排序到volatile读之前。
3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
JMM通过插入内存屏障指令来禁止特定类型的重排序。Java编译器在生成字节码时,在volatile变量操作前后的指令序列中插入内存屏障来禁止特定类型的重排序。
volatile内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障
在每个volatile写操作的后面插入一个StoreLoad屏障
在每个volatile读操作的前面插入一个LoadLoad屏障
在每个volatile读操作的后面插入一个LoadStore屏障
屏障类型
Store:数据对其他处理器可见(即刷新到内存中)
Load:让缓存中的数据失效,重新从主内存加载数据
LoadLoad
Load1; LoadLoad; Load2
确保Load1数据的转载,先于Load2及所有后续装载指令的装载。
StoreStore
Store1; StoreStore; Store2
确保Store1数据对其他处理器可见(刷新到内存),先于Store2及所有后续存储指令的存储。
LoadStore
Load1; LoadStore; Store2
确保Load1数据转载,先于Store2及所有后续的存储指令刷新到内存。
StoreLoad
Store1; StoreLoad; Load2
确保Store1数据对其他处理器可见(刷新到内存),先于Load2及所有后续装载指令的装载。
StoreLoad会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令。
不保证原子性
volatile是不能保证原子性的,既程序执行过程中不能保证不被其他线程执行
保证原子性的方式:synchronized锁机制、CAS来实现原子性操作(AtomicInteger等类型)
总结:
并发编程中,常用volatile修饰变量以保障变量的修改对其他线程可见。
volatile可以保证可见性和有序性,不能保证原子性。
volatile是通过插入内存屏障禁止重排序来保证可见性和有序性的。
final
关于final的几个知识点
final关键字可以提高性能,JVM和Java应用都会缓存final变量,JVM会对方法、变量及类进行优化。
在匿名类中所有变量都必须是fanal变量。
接口中声明的所有变量本身是final的。
按照Java代码惯例,final变量就是常量,而且通常常量名要大写。
final变量可以安全的在多线程环境下进行共享,而不需要额外的同步开销。
写final域
在狗仔函数内对一个final域的写入,与随后把这个被构造对象的应用赋值给一个引用变量,这两个操作之间不能重排序。
编译器会在final域的写之后,插入一个StoreStore屏障,这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
确保先写入对象的final变量,后调用该对象引用。
读final域
初次读一个包含final域的对象的应用,与随后初次读这个final域,这两个操作之间不能重排序。
编译器会在读final域操作的前面插入一个LoadLoad屏障,这个屏障可以禁止读对象引用和读对象final域重排序。
确保先读对象的应用,后续读对象的final变量。
final域为引用类型
对于引用类型,写final域的重排序规则对编译器和处理器增加了约束
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
1. 读写final域的约束依然有效
2. 确保先写入对象的final变量的成员变量,后调用该对象引用。
synchronized
基本上所有的并发模式在解决线程安全问题时,都采用“序列化访问临界资源”的方案,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
对于同步方法,JVM采用ACC_SYNCHRONIZED标记来实现同步。
对于同步代码块,采用monitorenter、monitorexit两个指令来实现同步。
关于ACC_SYNCHRONIZED
1. 方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。
2. 当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁(monitor),然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻塞
3. 如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前,监视器锁会被自动释放。
关于monitorenter、monitorexit
1. 可以吧执行mointorenter指令理解为加锁,执行monitorexit理解为释放锁。
2. 每个对象维护着一个记录着被锁次数的计数器。
3. 未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为1,当同一个线程再次获得该对象的锁时,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。
4. 当计数器为0的时候。锁将被释放,其他线程变可以获得锁。
Monitor
无论是同步方法还是同步代码块都是基于监视器Monitor实现
所有Java对象都是天生的Monitor,每一个Java对象都有成为Monitor的潜质
Monitor实现的几个关键属性:
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数
场景
线程A等待对象锁:_EntryList中加入A
线程A获取对象所:_EntryList移除A,_owner设置为A,计数器_count加1
线程A中锁对象调用wait():_owner设置为null,计数器_count减1,_WaitSet中加入A等待被唤醒
只有锁对象的线程A执行完毕:复位变量的值,以便其他线程进入获取monitor
保证原子性
synchronized修饰的代码在同一时间只能被一个线程访问,在锁为释放之前,无法被其他线程访问到。
即便在执行过程中,CPU时间片用完,但并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会由同一个线程继续执行代码,知道所有代码执行完。从而保障synchronized修饰的代码块在同一时间只能被一个线程访问。
单线程重排序要遵守as-if-serial语义,不管怎么重排序,单线程程序的执行结果都不能被改变。synchronized修饰的代码,同一时间只能被同一线程访问,那么也就是单线程执行的,所以可以保证其有序性。
加锁的含义不仅仅局限于互斥行为,还包括可见性。——《Java并发编程实战》
JMM关于synchronized的两条语义贵方保证了可见性:
线程解锁前,必须把共享变量的最新值刷新到主内存中
线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量是需要从主内存中重新读取最新的值。
疑问:如果在同步代码块执行过程中,存储了共享变量,工作内存中是否会有缓存? 在同一同步代码块中下面再次使用共享变量时是否可能已被其他线程修改共享变量的值。
synchronized优化
原因
Java线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,状态转换需要花费很多的处理器时间。
自旋锁与适应自旋锁
大多数情况,线程持有锁的时间都不会太长,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的,所以引入自旋锁。
自旋锁
当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。
在经过若干次循环后,如果得到锁,就顺利进入临界区;入股哦还不能获得锁,那就会将线程在操作系统层面挂起(无奈之举)。
自旋锁与阻塞的最大区别
是否放弃处理器的执行时间
阻塞放弃了CPU时间,进入了等待区,等待被唤醒,响应慢。
自旋锁一直占用CPU时间,时刻检查共享资源是否可以被访问,所以响应速度更快
缺点
如果持有锁的线程很快就释放了锁,那么组选的效率就非常好。但是如果持有锁的线程占用锁的时间较长,等待锁的线程自选一定次数后还是拿不到锁而被阻塞,那么自旋就拜拜浪费了CPU的资源。
所以自旋的次数直接决定了自旋锁的性能。JDK自旋的默认次数为10次,可以通过参数调整(-XX:PreBlockSpin)
自适应自旋锁
由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
线程如果自旋成功了,那么下一次自旋的次数会更加多
如果对于某个锁很少有自旋能够成功的,那么以后要获得该锁时,自旋的次数会减少,甚至忽略掉自旋过程,以免浪费CPU资源
锁消除
如果JVM检测到某段代码不可能存在共享数据竞争,JVM会对这段代码的同步锁进行锁消除。
在动态编译同步块时,JIT编译器可以借助一种被称为逃逸分析(EscapeAnalysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果是,那么JIT编译器在编译这个同步块的时候就会取消这部分代码的同步
锁粗话
将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
偏向锁、轻量级锁、重量级锁
从Java对象的markWord可以看到,synchronized锁一共包括四种状态:无锁、偏向锁、轻量级锁、重量级锁
偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁纸杯一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况
偏向锁
大多数情况下锁不仅不存在多线程竞争关系,而且总是由同一个线程多次获取,所以引入偏向锁让线程获得锁的代价更低。
一旦有不同的线程获取或竞争锁对象,偏向锁就升级为轻量级锁。
从而在无多线程竞争的情况下减少不必要的轻量级锁执行路径。
轻量级锁
在大多数情况下同步块并不会出现经常情况,大部分情况是不同线程交替持有锁,所以引入轻量级锁可以减少重量级锁对线程的阻塞带来的开销。
轻量级锁认为环境中线程几乎没有对锁对象的竞争,即使有竞争也只需要稍微等待(自旋)下就可以获取锁,但是自选次数有限制,如果超过该次数,则会升级为重量级锁。
重量级锁
监视器Monitor
锁的膨胀过程
无锁->偏向锁->轻量级锁->重量级锁
1. 一个锁对象刚开始创建的时候,没有任何线程来访问它,此时线程状态为无锁状态。Mark Word(锁标志位:01,是否偏向:0)
2. 当线程A来访问这个对象锁时,他会偏向这个线程A。线程A检查Mark Word(锁标志位:01,是否偏向:0)为无锁状态。Mark Word(锁标志位:01,是否偏向:1,线程ID: 线程A的ID)
有线程访问锁了,无锁升级为偏向锁
3. 当线程A执行完同步代码块时,不会主动释放偏向锁。Mark Word不变(锁标志位:01,是否偏向:1,线程ID: 线程A的ID)
持有偏向锁的线程执行完同步代码后不会主动释放偏向锁,而是等待其他线程来竞争才会释放锁
疑问:当线程A执行完成后,线程B来获取锁时,如何知道线程A是否执行完成?
1.全局安全点
2.线程状态
4. 当线程A再次获取这个对象锁时,检查Mark Word(锁标志位:01,是否偏向:1,线程ID: 线程A的ID),偏向锁且偏向线程A,可以直接执行同步代码。
偏向锁保证了总是同一个线程多次获取锁的情况下,每次只需要检查标志位就行,效率很高。
5. 当线程A执行完同步代码块之后,线程B获取这个对象所,检查Mark Word(锁标志位:01,是否偏向:1,线程ID: 线程A的ID),偏向锁且偏向线程A。偏向锁升级为轻量级锁,并由线程B获取该锁
有不同的线程获取锁对象,偏向锁升级为轻量级锁
6. 当线程A正在执行同步代码块时,也就是正持有偏向锁时,线程B来获取这个对象锁。检查Mark Word(锁标志位:01,是否偏向:1,线程ID: 线程A的ID),偏向锁且偏向线程A
线程A撤销偏向锁
1. 等到全局安全点执行撤销偏向锁,暂停持有偏向锁的线程A并检查线程A的状态;
2. 如果线程A不处于活动状态或者已经退出同步代码块,则将对象锁设置为无锁状态,然后再升级为轻量级锁。由线程B获取轻量级锁
3. 如果线程A还在执行同步代码块,也就是线程A还需要这个对象锁,则偏向锁膨胀为轻量级锁。
线程A膨胀为轻量级锁
1. 在升级为轻量级锁之前,持有偏向锁的线程A是暂停的
2. 线程A栈帧中创建一个名为锁记录的空间(Lock Record)
3. 锁对象头中的Mark Word拷贝到线程A的所记录中
4. Mark Word的锁标志位变为00,指向锁记录的指针指向线程A的锁记录地址,Mark Word(锁标志位:00,其他位:线程A锁记录的指针)
5. 当原持有偏向锁的线程A获取轻量级锁后,JVM唤醒线程A,线程A指向同步代码块
7. 线程A持有轻量级锁,线程A指向完同步代码块之后,一直没有线程来竞争对象锁,正常释放轻量级锁。
释放轻量级锁操作:CAS操作将线程A的锁记录(LockRecord)中的Mark Word替换回锁对象头中
8. 线程A持有轻量级锁,指向同步代码块过程中,线程B来竞争对象锁。Mark Word(锁标志位:00,其他位:线程A锁记录的指针)
1. 线程B会现在栈帧中建立锁记录,存储锁对象目前的Mark Word的拷贝
2. 线程B通过CAS操作尝试将锁对象的Mark Word的指针指向线程B的Lock Record,如果成功,说明线程A刚刚释放锁,线程B竞争到锁,则执行同步代码块
3. 因为线程A一直持有锁,大部分情况下CAS是失败的。CAS失败之后,线程B尝试使用自旋的方式来等待持有轻量级锁的线程释放锁。
4. 线程B不会一直自旋下去,如果自旋了一定次数后还是失败,线程B会被阻塞,等待释放锁后唤醒。此时轻量级锁就会膨胀为重量级锁。Mark Word(锁标志位:10,其他位:重量级锁Monitor的指针)
5. 线程A执行完同步块代码之后,执行释放锁操作,CAS操作将线程A的锁记录(Lock Record)中的Mark Word替换回锁对象对象头中,因为对象头中已经不是原来的轻量级锁的指针了,而是重量级锁锁的指针,所以CAS操作会失败。
6. 释放轻量级锁CAS操作替换失败之后,需要在释放锁的同时唤醒被挂起的线程B。线程B被唤醒,获取重量级锁Monitor
并发基础
线程基础
并发编程可以更好的利用CPU资源,更高效快速的响应程序,但是设计较复杂,并且上下文切换会造成一定的消耗
并发编程中,由于多个线程同时访问同一个资源,可能造成线程安全问题,Java中可以通过synchronized和Lock的方式实现同步解决线程安全问题。
更好的发挥多线程的优势需要线程之间通信,常用的线程通信方式是通过共享对象的状态通信和wait()/notify()。
多个线程同时但以不同的顺序请求同一组锁的时候,线程之间互相循环等待锁导致线程一直阻塞,造成死锁。最常用的解决死锁方式是按顺序加锁。
线程持有不可重入锁之后再次请求不可重入锁时被阻塞,就是重入死锁。
如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为饥饿。
线程状态
新建状态(NEW)
JVM违期分配内存,并初始化期成员变量的值
就绪状态(RUNNABLE)
JVM会为期创建方法调用栈和程序计数器
等待系统违期分配CPU时间片
运行状态(RUNNING)
当线程用完系统分配的CPU时间片后,将从运行状态变为就绪状态,重新等待分配CPU资源
阻塞状态(BLOCKED)
处于运行状态的线程在某些情况下,让出CPU并暂时停止自己的运行,进入阻塞状态。如:synchronized锁
等待状态(WAITING)
线程处于无限制等待状态,等待一个特殊的时间来重新唤醒,唤醒线程之后进入就绪状态
如:通过wait()方法进行等待的线程,等待一个notify()或者notifyAll()方法;
如:通过join()方法进行等待的线程,等待目标线程运行结束而唤醒。
注意:阻塞在Lock接口的线程状态不是BLOCK状态,而是WAITING等待状态。因为Lock接口对于阻塞的实现均使用了LockSupport类中相关方法
超时等待状态(TIMED_WAITING)
sleep(2000)
死亡状态(DEAD)
run()或call()方法执行完成
直接调用stop(),容易导致死锁,不推荐
线程优先级
Java提供10个优先级别,但这些优先级别需要操作系统的支持
所以最好使用Max_Priority、Min_Priority和Norm_Priority三个静态常量来设定优先级,以保证程序更好的可移植性
优先级不能作为程序正确性的依赖,操作系统可以完全不用理会Java线程对于优先级的设定
Deamon守护线程
守护线程是一只支持型线程,在后台守护一些系统服务,比如Java的垃圾回收、内存管理等线程都是守护现场
用户想吃结束后,系统就没有对象需要守护了,守护线程会自动退出
所以:当一个Java应用只有守护线程的时候,虚拟机就会自然退出。
注意:
调用setDaemon(boolean on)设置收货线程要在线程启动前,否则会抛出异常。
守护线程在退出的时候并不会执行finnaly块中的代码,所以释放资源等操作不要放在守护线程的finnaly块中执行
线程中断
每个线程都管理了一个中断状态,用boolean值表示,初始值为false。中断一个线程,其实就是设置了这个线程的中断状态boolean值为true
注意:中断只是一个状态,处于中断状态的线程不一定要停止运行。
设置线程的中断状态为true:interrupt()
检测线程中断状态,处于中断状态返回true:isInterrupted()
经常线程终端状态,处于中断状态返回true,同时将中断状态重置为false:静态方法 interrupted()
当一个线程处于sleep、wait、join这三种状态之一时,如果此时线程中断状态为true,那么会抛出一个InterruptedException的一次,并将中断状态重新设置为false
join
当一个线程必须等待另一个线程执行时,就用到join
sleep
sleep是Thread的静态方法,让线程进入到阻塞状态,交出CPU,让CPU去执行其他任务
注意:sleep方法不会释放锁
yield
让正在执行的线程进入到就绪状态,让出CPU资源给其他线程
wait
monitor场景
锁对象调用notify():从存放处于wait状态的线程队列_WaitSet中随意选择一个线程A,将线程A从_WaitSet中移到_EntryList中重新去竞争锁
同步队列(锁池/ _EntryList):由于线程没有竞争到锁,只能等待锁释放之后再去竞争,此时线程就处于该对象的同步队列(锁池)中,线程状态为BLOCKED
等待队列(等待池/ _WaitSet):线程调用了wait方法之后被挂起,等待notify唤醒或者挂起时间到自动唤醒,此时线程就处于该对象的等待队列(等待池)中,线程状态为WAITING或者TIMED_WAITING
wait方法:释放持有的对象锁,线程状态有RUNNING变为WAITING,并将当前线程防止到对象的等待队列
notify方法:在目标对象的等待集合中随意选择一个线程T,将线程T从等待队列移到同步队列重新竞争锁,线程状态由WAITING变为BLOCKED
不能保证哪个线程被选择
wait & notify/notifyAll 这三个都是 Object 类的方法
使用 wait,notify 和 notifyAll 前提是先获得对象的锁
CAS
简介
Compare And Swap(比较并交换)
CAS过程(V\\A\\B)
V:一个内存地址存放的实际值
A:旧的预期值
B:即将更新的值
当且仅当预期值A和内存值V相同时,将内存值修改为B并放回true,否则什么都不做并返回false
CAS与synchronized
synchronized中线程获取锁是一种悲观锁策略,即假设每一次执行临界区diam阿都汇产生冲突,所以当前线程获取到锁之后会阻塞其他线程获取到该锁
CAS(无锁操作)是一种乐观锁策略,假设所有线程访问共享资源的时候不会出现冲突,所以出现冲突时就不会阻塞其他线程的操作,而是充实当前操作直到没有冲突为止
AtomicInteger源码(getAndIncrement方法)
Unsafe:是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。
valueOffset:表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的原值。
value:用volatile修饰,保证多线程之间的内存可见性
1. 根据对象var1和对象中该变量地址var2(valueOffset)获取变脸当前的值value
2. 比较value与var5,如果value==var5,则value=var5+var4并返回true
比较和替换操作,原子性
3. 如果value!=var5,则返回false,再去自旋循环到下一次调用compareAndSwapInt方法
compareAndSwapInt方法是JNI,Java通过C来调用CPU底层指令实现的。
compareAndSwapInt方法中的比较替换操作之前插入一个Lock前缀指令,这个指令能确保后续操作的原子性。
CPU两种实现多处理器的原子操作
总线加锁
使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。锁定期间,其他处理器都不能使用其他内存地址的数据。
缓存加锁
实际上只需要保证在同一时刻对某个内存地址的操作是原子性的即可。
缓存在内存区域的数据如果在加锁期间,当他执行锁操作写回内存时,处理器不在输出LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当CPU1修改缓存行中的i时使用缓存锁定,那么CPU2就不能同时缓存了i的缓存行
比较替换过程分析
1. 假设线程A和线程B同时调用a.getAndIncrement(),AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,且线程A和线程B各自持有一份value的副本,值为3
3. 线程A执行compareAndSwapInt()方法比较和替换时,其他CPU无法访问该变量的内存,所以线程B不能进行比较替换。线程A成功修改内存值为4,返回true,执行结束。
CAS的问题
ABA问题
CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会认为没有改变,但是实质上它已经发生了改变,这就是ABA问题。
解决方式:增加版本号,原来的变化路径A->B->A 就变成了 1A -> 2B -> 3A
java1.5之后的atomic包中提供了AtomicStampedReference来解决该问题
自旋时间过长
CAS不会将线程挂起,会自旋进行下一次尝试,如果自旋CAS长时间不成功,则会给CPU带来非常大的开销。
优化:限制CAS自旋的次数,例如BlockingQueue的SynchronousQueue
只能保证一个共享变量的院子操作
如果对多个共享变量进行操作,CAS就不能保证其原子性
解决:将多个变量整合成一个变量
利用对象整合多个共享变量,寄一个类中的成员变量就是这几个共享变量,然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性
利用变量的高低位,如 JDK 读写锁 ReentrantReadWriteLock 的 state,高 16 位用于共享模式 ReadLock,低 16 位用于独占模式 WriteLock
Java中的院子操作类
基本数据类型的原子操作类
AtomicInteger
AtomicLong
AtomicBoolean
数组类型的原子操作类
AtomicIntegerArray
AtomicLongArray
AtomicReferenceArray(引用类型数组)
引用类型原子操作类
AtomicReference
字段类型原子操作类
AtomicIntegeFieldUpdater
AtomicLongFieldUpdater
AtomicStampedReference
带有版本号,解决CAS的ABA问题
LockSupport
LockSupport 提供 park()和 unpark()方法实现阻塞线程和解除线程阻塞
Object 的 wait()/notify 方法需要获取到对象锁之后在同步代码块里才能调用,而 LockSupport 不需要获取锁。所以使用 LockSupport 线程间不需要维护一个共享的同步对象,从而实现了线程间的解耦
unpark()方法可提前 park()方法调用,所以不需要担心线程间执行的先后顺序
多次调用 unpark()方法和调用一次 unpark()方法效果一样,因为 unpark 方法是直接将_counter 赋值为 1,而不是加 1
许可不可重入,也就是说只能调用一次 park()方法,如果多次调用 park()线程会一直阻塞
0 条评论
回复 删除
下一页