赞
踩
为了更快的响应,同时合理的利用CPU资源。
但是数据传输过程中都经过CPU、内存、I/O 设备等,这些速度都有极大的差异,如何均衡计算机体系结构、操作系统、编译程序都做出了优化。
可见性
问题原子性
问题有序性
问题一个线程对共享变量的修改,另外一个线程能够立刻看到。
即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
程序执行的顺序按照代码的先后顺序执行。
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
源代码-》编译器重排序-》指令级重排序-》内存重排序-》最终执行的指令序列
这些重排序都可能会导致多线程出现内存可见性问题,对于编译器,JVM有提供内存屏蔽指令(volatile关键字)来禁止特定类型的编译器重排序和处理器重排序。
为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的编译器重排序和处理器重排序。**不同硬件实现内存屏障的方式不同,Java内存屏障主要有Load和Store两类。**JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。 |
另外也可以通过Volatile关键字来实现屏障指令效果。
Intel硬件提供了一系列的内存屏障,主要有:
1、lfence,是一种Load Barrier 读屏障
2、sfence, 是一种Store Barrier 写屏障
3、mfence, 是一种全能型的屏障,具备ifence和sfence的能力
4、Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。分别是:volatile(可见性、有序性)、synchronized (原子性、可见性)和 final 三个关键字和Happens-Before 规则。
在并发编程中,线程之间的通信机制有两种:共享内存和消息传递。在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。JMM 通过控制主内存与每个线程的本地内存之间的交互,来保证内存可见性。
在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理器参数在当前线程的栈中,不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值,以此来保证可见性。volatile主要用来防重排序。
volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。
**volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。**volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
内存屏障 | 说明 |
---|---|
StoreStore 屏障 | 禁止上面的普通写和下面的 volatile 写重排序。 |
StoreLoad 屏障 | 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。 |
LoadLoad 屏障 | 禁止下面所有的普通读操作和上面的 volatile 读重排序。 |
LoadStore 屏障 | 禁止下面所有的普通写操作和上面的 volatile 读重排序。 |
内存屏障,又称内存栅栏,是一个 CPU 指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止+ 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。
LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了缓存一致性协议。 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。
所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。
CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock通过加锁能够保证任一时刻只有一个线程执行该代码块,并且在释放锁之前会将对变量的修改刷新到主存当中,从而保证了原子性、可见性、有序性。
1)基础使用
修饰类。被修饰的类不能被继承。当遇到数据结构需要当前类,可以采用开发原则组合复用原则。
**修饰方法。**不能被重写,但是可以被重载,private 方法是隐式的final。
修饰参数。无法在方法中更改参数引用所指向的对象。这个特性主要用来向匿名内部类传递数据。
**修饰变量。**修饰的变量不可变。Java允许生成空白final,也就是说被声明为final但又没有给出定值的字段,但是必须在该字段被使用之前被赋值。必须在构造函数退出前设置它的值。
2)final域重排序规则
按照final修饰的数据类型分类:
final域写
:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。final域读
:禁止初次读对象的引用与读该对象包含的final域的重排序。额外增加约束
:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序3)final的实现原理
final域的重排序也是在指定位置插入屏障指令。写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障。
JMM遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。
单一线程原则。单一线程内,代码顺序决定执行顺序。
管程锁定规则。对于同一个锁,解锁(UnLock)总是发生在加锁之前(Lock)。
volatile 变量规则。对于同一个Volatile变量**,写操作总是发生在读操作之前。
线程启动规则。一个线程的 start()操作,总是发生在这个线程所有动作之前。
线程加入规则。Thread对象的结束先行发生于 join() 方法返回。
线程中断规则。对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
对象终结规则。一个对象的初始化操作总是发生在它的finalize方法之前。
传递性。如果A先于B,B先于C,那么A先于C。
JMM 把 happens- before 要求禁止的重排序两类:
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM 对这两种不同性质的重排序,采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。
详见JVM中的锁Synchronized锁优化
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。
1)Synchronized的使用
1-1、对象锁:代码块形式,包含方法锁(默认锁对象为this,当前实例对象)和同步代码块锁(自己指定锁对象)
1-2、方法锁:synchronized修饰普通方法,锁对象默认为this
1-3、类锁:synchronize修饰静态的方法或指定锁对象为Class对象
2)Synchronized原理分析
2-1) 加锁和释放锁的原理
任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED(阻塞),当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。
Monitorenter
和Monitorexit
指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得。monitor计数器为0,代表目前没有被获得。
2-2)可重入原理:加锁次数计数器
可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
即在同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加一,释放锁后就会将monitor计数器减一,线程不需要再次获取同一把锁。
2-3)保证可见性的原理:内存模型和happens-before规则
Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。
3)JVM中锁的优化
JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境如果每次都调用Mutex Lock那么将严重的影响程序的性能。
在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
锁粗化(Lock Coarsening)
:也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
锁消除(Lock Elimination)
:通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护。通过逃逸分析也可以在线程本的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
轻量级锁(Lightweight Locking)
:在无锁竞争的情况下避免调用操作系统层面的重量级互斥锁,取而代之的是在**monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(**具体处理步骤下面详细讨论)。
偏向锁(Biased Locking)
:当一个线程访问同步块并获取锁时,会在对象头的Mark Word
和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。只需要验证是否指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。偏向锁使用了一种等待竞争出现才会释放锁的机制,假如出现了竞争,会等当前持有锁的对象执行完毕,直接将对象头设置为无锁状态,若此时有多个线程竞争,就膨胀为重量级锁;若只有一个线程抢夺,则是轻量级锁。
适应性自旋(Adaptive Spinning)
:当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。
自旋锁
:在JDK1.4 中引入了,在JDK 1.6后默认为开启状态。在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin
来更改。
CAS是CPU的一条指令,其具有原子性,原子性是由CPU硬件层面保证的。内存位置(V)、预期原值(A)、新值(B)。若内存位置与预期原值匹配则处理器将该位置更新为新值。否则不做操作。无论何种情况都会在CAS指令之前返回该位置值。这个过程是原子性的。
Synchronized一共有四种状态:无锁
、偏向锁
、轻量级锁
、重量级锁
,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
轻量级锁和偏向锁的区分,在Java对象头中(
Object Header
)存在两部分。第一部分用于存储对象自身的运行时数据,HashCode
、GC Age
、锁标记位
、是否为偏向锁
。等。一般为32位或者64位(视操作系统位数定)。官方称之为**Mark Word
,它是实现轻量级锁和偏向锁的关键**。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point
),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。
在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record
)的空间,用于存储锁对象目前的Mark Word
的拷贝(JVM会将对象头中的Mark Word
拷贝到锁记录中。
如果当前对象没有被锁定,那么锁标志位为01状态。
JVM使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word
更新为指向Lock Record
的指针。
如果更新成功了,那么这个线程就拥用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word
中最后的2bit)00,即表示此对象处于轻量级锁定状态。
如果这个更新操作失败,JVM会检查当前的Mark Word
中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用,是偏向锁。如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,直接膨胀为重量级锁,没有获得锁的线程会被阻塞。锁的标志位为10.Mark Word
中存储的指向重量级锁的指针。
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。
ReentrantLock实现了Lock接口,Lock接口中定义了lock与unlock相关操作,并且还存在newCondition方法,表示生成一个条件。 ReentrantLock类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。
Lock类的4个方法:
lock()
: 加锁unlock()
: 解锁tryLock()
: 尝试获取锁,返回一个boolean值tryLock(long,TimeUtil)
: 尝试获取锁,可以设置超时
1)Sync类存在如下方法和作用如下。
2)NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现了Sync类中抽象的lock方法。
3)FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中的抽象lock方法。跟踪lock方法的源码可知,当资源空闲时,它总是会先判断sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。
分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。如将ReentrantLock的lock函数转化为对Sync的lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到Sync的不同子类。
基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。 但ABA存在的问题,改用传统的互斥同步可能会比原子类更高效。
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。例如:锁消除。
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
在一些场景 (尤其是使用线程池) 下,**由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),**以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。
可重入代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。