JAVA并发体系-2-锁机制
通过锁可以实现受限制资源的共享,序列化共享资源的访问。java提供了一套用于锁的机制,这套机制里主要的锁就是关键字synchronized和concurrent包中的lock类。另外也需要记住这一点:多线程加锁虽然实现互斥,但是很可能降低了处理速度,带来严重的性能问题。
为了解决问题,不得不处理这样的复杂性。虽然复杂性会带来性能、可读性、可维护性上的诸多的问题。
算法原理
CAS算法
CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。concurrent包中的原子类就是通过CAS实现的。(可以查看并发实现机制-2-互斥实现中的硬件互斥部分获得其他信息)
CAS算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。
CAS虽然很高效,但是它也存在三大问题
-
ABA问题
CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
- JDK从1.5开始提供了
AtomicStampedReference
类来解决ABA问题,具体操作封装在compareAndSet()
中。compareAndSet()
首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
- JDK从1.5开始提供了
-
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
-
只能保证一个共享变量的原子操作
对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
- Java从1.5开始JDK提供了
AtomicReference
类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
- Java从1.5开始JDK提供了
AQS
todo: 自增计数;todo: 关于AQS请查看后面的lock小节,了解继承体系
在Java中有AQS(AbstractQueuedSynchronizer
)AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
锁
锁名词
(请注意:本部分参考了文末参考2,主要图文均来自该文章)
Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。本小节专门论述不同类型的锁和锁现象(介绍名词向的小节),例如:乐观锁、悲观锁;自旋锁、适应性自旋锁;无锁、偏向锁、轻量级锁、重量级锁;公平锁、不公平锁;可重入锁(递归锁)、不可重入锁;共享锁、排他锁(独享锁)(互斥锁)。
- 同步资源是否上锁:乐观锁、悲观锁
- 是否选择堵塞:自旋锁、适应性自旋锁
- 锁的状态:无锁>偏向锁>轻量级锁==>重量级锁
- 获取锁的策略:是否需要排队:公平锁、不公平锁
- 可重入锁(递归锁)、不可重入锁
- 共享锁、排他锁(独享锁)(互斥锁)
乐观锁 & 悲观锁
乐观锁与悲观锁体现了看待线程同步的不同角度。
- 悲观锁:对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
- synchronized关键字和Lock的实现类都是悲观锁
- 乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
- 如果这个数据没有被更新,当前线程将自己修改的数据成功写入。
- 如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)
- Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的
适合的场景:
- 悲观锁:适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁:适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
自旋锁 & 适应性自旋锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。
为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
自旋锁的实现原理同样也是CAS。有三种常见的锁形式:TicketLock
、CLHlock
和MCSlock
。
自旋锁本身是有缺点的,即它要占用处理器时间:
- 锁被占用的时间很短,自旋等待的效果就会非常好
- 锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源
所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin
来更改)没有成功获得锁,就应当挂起线程。
适应性自旋锁
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
-
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
-
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
无锁 & 偏向锁 & 轻量级锁 & 重量级锁
这四种锁是指锁的状态,专门针对synchronized的。锁状态只能升级不能降级.
- 无锁:
- 描述:没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功
- 过程:修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。
- CAS原理及应用即是无锁的实现
- 偏向锁:
- 描述:一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。(在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。)
- 过程:过程1==>过程2
- 过程1:线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID
- 过程2:线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁
- 轻量级锁:
- 描述:偏向锁升级为轻量级锁后,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能
- 过程:过程1>过程2>过程2.1、过程1>过程2>过程2.2>过程2>过程2.2>......>过程3
- 过程1:代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
- 过程2:拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word
- 过程2.1:如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
- 过程2.2:如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
- 过程3:若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁
- 重量级锁:
- 描述:为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
对比:
- 偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。
- 轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
- 重量级锁是将除了拥有锁的线程以外的线程都阻塞。
- 引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换
ThreadID
的时候依赖一次CAS原子指令即可(todo: 依然不是很能理解)
状态转换:
- 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁
- 偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
- 处于轻量级锁时:若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
公平锁 & 非公平锁
ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁
- 公平锁:
- 描述:多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁
- 优点:等待锁的线程不会饥饿
- 缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大
- 非公平锁:
- 描述/过程:
- 多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。
- 如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景
- 优点:可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
- 缺点:等待队列中的线程可能饥饿
- 描述/过程:
可重入锁 & 非可重入锁
ReentrantLock和synchronized都是可重入锁
ReentrantLock
和NonReentrantLock
都继承父类AQS
- 可重入锁:(递归锁)
- 描述:线程在持有一个对象或class的锁时,再次尝试获取该对象的其他方法、实例变量等的锁会自动获取锁,不会因为之前已经获取过还没释放而阻塞。(锁计数会加一)
- 例如线程获取了对象
doSomething
方法上的锁,doSomething
方法体中需要调用doOthers
,那么线程可以获得doOthers
的锁
- 例如线程获取了对象
- 优点:一定程度避免死锁
- 描述:线程在持有一个对象或class的锁时,再次尝试获取该对象的其他方法、实例变量等的锁会自动获取锁,不会因为之前已经获取过还没释放而阻塞。(锁计数会加一)
- 非可重入锁:(与可重入锁相反,顾名思义)
共享锁 & 排他锁
synchronized和JUC中Lock的实现类就是互斥锁
独享锁与共享锁也是通过AQS来实现的
- 共享锁:
- 描述:该锁可被多个线程所持有
- 特点:
- 线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。
- 获得共享锁的线程只能读数据,不能修改数据。
- 排他锁:(独享锁)(互斥锁)
- 描述:锁一次只能被一个线程所持有。
- 特点:
- 线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁
- 获得排它锁的线程即能读数据又能修改数据
synchronized
(请对照上述的无锁 & 偏向锁 & 轻量级锁 & 重量级锁来理解)
java提供关键字synchronized(该关键词检查锁是否可用、然后获取锁、执行代码、释放锁)
- 对于某个特定对象来说,其所有 synchronized方法共享同一个锁,这可以被用来防止多个任务同时访问被编码为对象内存。
- 一个任务可以多次获得对象的锁,在任务第一次给对象加锁的时候,计数变为1,毎当这个相同的任务在这个对象上获得锁时,计数都会递增,每当任务离开一个 synchronized方法,计数递减,当计数为零的时候,锁被完全释放,此时别的任务就可以使用此资源。
- 针对每个类,也有一个锁(作为类的Cas对象的一部分),所以 synchronized static方法可以在类的范围内防止对 static数据的并发访问。
- 注意,在使用井发时,将域设置为 private是非常重要的,否则, synchronized关键字就不能防止其他任务直接访问域,这样就会产生冲突。
临界区构建
synchronized(syncObject){//在进入此段代码之前,必须得到syncObject的锁
// this code can be accessed by only one task at a time
}
-
如果在this上同步即synchronized(this)那么临界区的效果就会直接缩小在同步的范围内。
-
有时必须在另一个对象上同步,但是如果你要这么做,就必须确保所有相关的任务都是在同一个对象上同步的(如方法A和方法B都使用了一个list变量域来进行添加删除元素,那么就要使用同一个对象来对其同步)。
下面的示例演示了两个任务可以同时进入同一个对象,只要这个对象上的方法是在不同的锁上同步的即可。下面的示例中,两个方式在同时运行,因此任何一个方法都不会因为另一个方法的同步而被堵塞。
class DualSynch { private Object syncObject = new Object(); public synchronized void f() { // sync this for(int i = 0; i < 5; i++) { print("f()"); Thread.yield(); } } public void g() { synchronized(syncObject) { // syncObject for(int i = 0; i < 5; i++) { print("g()"); Thread.yield(); } } } }
为什么Synchronized能实现线程同步?
todo: 进一步整理,本小节没有进行很好的整理!请注意!
在回答这个问题之前我们需要了解两个重要的概念:“Java对象头”、“Monitor”。
Java对象头
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?
我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
通过上面的介绍,我们对synchronized的加锁机制以及相关知识有了一个了解,那么下面我们给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:
锁状态 | 存储内容 | 存储内容 |
---|---|---|
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
lock
使用显式的Lock对象。即使用lock.lock()和lock.unlock()来包围住需要加锁的区域。对于某些类型的问题更为灵活。使用lock的示例如下:
private Lock lock = new ReentrantLock();
public int next() {
lock.lock();
try {
++currentEvenValue;
Thread.yield(); // Cause failure faster
++currentEvenValue;
return currentEvenValue;
} finally {
lock.unlock();
}
}
lock的小小概况
-
Lock方式来获取锁支持中断、超时不获取、是非阻塞的
-
提高了语义化,哪里加锁,哪里解锁都得写出来
-
Lock显式锁可以给我们带来很好的灵活性,但同时我们必须手动释放锁
-
支持Condition条件对象
-
允许多个读线程同时访问共享资源
synchronized和lock对比
-
清理资源上:如果在使用synchronized关键字时,某些事物失败了,那么就会抛出一个异常。但是你没有机会去做任何清理工作,以维护系统使其处于良好状态。有了显式的Lock对象,你就可以使用 finally子句将系统维护在正确的状态了。
-
易用程度上:当你使用synchronized关键字时,需要写的代码量更少,并且用户错误出现的可能性也会降低,因此通常只有在解决特殊问题时,才使用显式的Lock对象。
- 例如:用synchronized关键字不能尝试着获取锁且最终获取锁会失败,或者尝试着获取锁一段时间,然后放弃它,要实现这些,你必须使用concurrent类库
-
获取锁这件事上:ReentrantLock允许你尝试着获取但最终未获取锁,这样如果其他人已经获取了这个锁,那你就可以决定离开去执行其他一些事情,而不是等待直至这个锁被释放(而synchronized会一直堵塞在这里)。
-
灵活性上:显式的Lock对象在加锁和释放锁方面,相对于内建的 synchronized锁来说,还赋予了你更细粒度的控制力。这对于实现专有同步结构是很有用的,
- 例如用于遍历链接列表中的节点的节节传递的加锁机制(也称为锁耦合),这种遍历代码必须在释放当前节点的锁之前捕获下一个节点的锁
-
临界区构建上:
-
使用synchronized可以构建临界区。
synchronized(syncObject){//在进入此段代码之前,必须得到syncObject的锁 // this code can be accessed by only one task at a time }
-
也可以使用lock对象
lock.lock()
;和lock.unlock()
;包围起来一个临界区。不一定在同一个区域。但是这样设计就会增加复杂性,很可能陷入死锁和饥饿
-
原子性和可视性
在向读者介绍这两个问题和volatile时,可能有必要引述编程思想上这几句话,在本节结束时会再次重复以免读者忘记这一忠告。
如果你是一个并发专家,或者你得到了来自这样的专家的帮助,你才应该使用原子性来代替同步。如果你认为自己足够聪明可以应付这种玩火似的情况,那么请接受下面的测试:
- 如果你可以编写用于现代微处理器的高性能JVM,那么就有资格去考虑是否可以进免同步
- 这个测试的一个推论是:“如果某人表示线程机制很容易并且很简单,那么请确保这个人没有对你的项目做出重要的决策。如果这个人已经在这么做了,那么你就已经陷入麻烦之中了。”
请不要简单的因为炫技或者偷懒而不加思考的使用原子类和利用原子性、可视性。
原子性
-
在有关Java线程的讨论中,一个经常不正确的知识是“原子操作不需要进行同步控制”。
原子操作是不能被线程调度机制中断的操作;一旦操作开始,那么它一定可以在可能发生的“上下文切换”之前(切换到其他线程执行)执行完毕。依赖于原子性是很棘手且很危险的。
-
原子性可以应用于除long和 double之外的所有基本类型之上的“简单操作”。
但是JVM可以将64位(long和 double变量)的读取和写入当作两个分离的32位操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换,从而导致不同的任务可以看到不正确结果的可能性(这有时被称为字撕裂,因为你可能会看到部分被修改过的数值)。
但是,当你定义long或 double变量时,如果使用 volatile关键字,就会获得(简单的赋值与返回操作的)原子性。
-
Java中++和--不是原子性的,涉及到一个读操作和写操作
可视性
可视性(易变性)
-
相对于单处理器系统而言,在多处理器系统上,可视性问题远比原子性问题多得多。
一个任务做出的修改,即使在不中断的意义上讲是原子性的,对其他任务也可能是不可视的(例如,修改只是暂时性地存储在本地处理器的缓存中),
-
另一方面,同步机制强制在处理器系统中,一个任务做出的修改必须在应用中是可视的。如果没有同步机制,那么修改时可视将无法确定。
-
应当和volatile关键字结合的来看待可视性问题
volatile关键字
-
定义long或 double变量时,如果使用 volatile关键字,就会获得(简单的赋值与返回操作的)原子性,避免字撕裂
-
volatile关键字会导致相应的域向主存中刷新,这确保了应用中的可视性。
即便使用了本地缓存,情况也确实如此, volatile域会立即被写入到主存中,而读取操作就发生在主存中。
-
如果多个任务在同时访问某个域,那么这个城就应该是 volatile,否则,这个域就应该只能经由同步来访问。
-
同步也会导致向主存中刷新,因此如果一个域完全由 synchronized方法或语句块来防护,那就不必将其设置为是volatile的。
-
在非 volatile域上的原子操作不必刷新到主存中去,因此其他读取该域的任务也不必看到这个新值。如果多个任务在同时访问某个域,那么这个城就应该是 volatile,否则,这个域就应该只能经由同步来访问。
-
无法工作的情形:
- 当一个域的值依赖于它之前的值时(例如递增一个计数器), volatile就无法工作了。
- 如果某个域的值受到其他域的值的限制,那么 volatile 也无法工作,例如 Range:类的lower和upper边界就必须遵循lower<=upper的限制。
一个例子
看下面一个例子,该程序将找到奇数值并终止。尽管 return i
确实是原子性操作,但是缺少同步使得其数值可以在处于不稳定的中间状态时被读取。除此之外,由于i
不是 volatile的,因此还存在可视性问题。
因此 getValue
和 evenIncrement
必须是 synchronized的。
public class AtomicityTest implements Runnable {
private int i = 0;
public int getValue() { return i; } // 注意这一句,不稳定的返回 注意:本句return是原子性操作
private synchronized void evenIncrement() { i++; i++; }
public void run() {
while(true)
evenIncrement();
}
}
基本上,如果一个域可能会被多个任务同时访问,或者这些任务中至少有一个是写入任务,那么就应该将这个域设置为 volatile的。但是, volatile并不能对递增不是原子性操作这一事实产生影响。
如下所示。对基本类型的读取和赋值操作被认为是安全的原子性操作。但是,正如你在上面的AtomicityTest
中看到的,当对象处于不稳定状态时,仍旧很有可能使用原子性操作来访问它们。
public class SerialNumberGenerator {
private static volatile int serialNumber = 0;
public static int nextSerialNumber() {
return serialNumber++; // Not thread-safe 不是线程安全的
}
} ///:~
如果你是一个并发专家,或者你得到了来自这样的专家的帮助,你才应该使用原子性来代替同步。如果你认为自己足够聪明可以应付这种玩火似的情况,那么请接受下面的测试:
- 如果你可以编写用于现代微处理器的高性能JVM,那么就有资格去考虑是否可以进免同步
- 这个测试的一个推论是:“如果某人表示线程机制很容易并且很简单,那么请确保这个人没有对你的项目做出重要的决策。如果这个人已经在这么做了,那么你就已经陷入麻烦之中了。”
请不要简单的因为炫技或者偷懒而不加思考的使用原子类和利用原子性、可视性。
原子类
这部分需要结合本文上面的CAS算法小节来了解。原子类是基于比较交换指令的,
原子类在常规编程很少会派上用场,但涉及性能调优时,他们就大有用武之地。 应该强调的是, Atomic类被设计用来构建 java util. concurrent
中的类,因此只有在特殊情况下才在自己的代码中使用它们,即便使用了也需要确保不存在其他可能出现的问题。通常依赖于锁要更安全一些(要么是 synchronized关键字,要么是显式的Lock对象)
针对原子类以及jdk8的新的原子类,在另一篇文章中有介绍,读者可以去另一篇文章深入了解。todo: 在这里应当引用另一篇文章的链接
参考
- Java 编程思想 第四版 中文版 倒数第二章
- 美团技术团队:不可不说的Java"锁"事