Java并发基础2-原子操作
接上文,在并发情况下,使用了volatile
关键字,能否保证多线程情况下结果的准确性?答案是不能,验证如下:
//自定义的类 |
主方法为
public static void main(String[] args) { |
代码如上所示,如果volatile保证原子性,那么10个线程分别执行自加2000次操作,那么最终结果一定是20000,但是执行三次结果如下。
//第一次 |
可以发现,我们num的值每次都不相同,且最后的值都没有达到20000,这是为什么呢?
为什么会出现这种情况?
首先,我们要考虑到这种情况,假如线程A执行到第11行即myTest.numPlusPlus();
方法时,线程A将从主存中读取到num的值为0,之后num的值自增为1,之后线程A挂起,线程B此时也将主存中的num值读到自己的工作内存中(此时线程A还未将自增后的num写入主存,因此线程B读到的num还是0),并将num的值自增1,之后线程B挂起,线程A继续运行将num的值写回主存(此时主存num值为1),然后线程B继续运行,也将值为1的num写回主存,最终两个线程一共执行了两次自增操作,但是主存中的结果只有一次。其根本原因在于这段代码有两个步骤:读和写,volatile只保证了读操作进行时和主存的一致性,但是未保证写操作的原子性。
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
要解决这个问题,就要保证变量操作的原子性。
要实现Java操作的原子性,有以下几种方式:
一、synchronized
synchronized中文意思是同步,也称之为”同步锁“。synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果(同一时间只有一个线程运行,也就是串行执行)。
1.1 synchronized的用法
用于方法
class B { |
用于代码块
class A { |
1.2 synchronized锁的种类
类锁
类锁,是用来锁类的,我们知道一个类的所有对象共享一个class对象,共享一组静态方法,类锁的作用就是使持有者可以同步地调用静态方法。当synchronized指定修饰静态方法或者class对象的时候,拿到的就是类锁,类锁是所有对象共同争抢一把。
//B中有两个方法mB和mC |
对象锁
对象锁,是用来对象的,虚拟机为每个的非静态方法和非静态域都分配了自己的空间,不像静态方法和静态域,是所有对象共用一组。
所以synchronized修饰非静态方法或者this的时候拿到的就是对象锁,对象锁是每个对象各有一把的,即同一个类如果有两个对象。
//类C中有两个方法mB和mC |
1.3 对象锁和类锁的使用
对象锁
下例中,两个线程调用同一个对象b的mB方法。最终结果是输出了1000次“1”之后输出了1000次“2”。可见两个线程对此方法的调用实现了同步。
class B { |
类锁
下面代码中对静态方法mA和mC的调用实现了同步,结果没有交替输出1和2,而是一次性输出完成后再输出的另一种
class B { |
类锁和对象锁同时存在
同时存在的情况下,两者做不到同步。类锁和对象锁是两种锁。下述情况,类B的静态方法和代码块功能都是打印100个value值,但是静态方法是类锁,而代码块锁this,是对象锁。所以代码块和静态方法交替执行、交替打印,大家可复制代码自行验证。
class B { |
二、原子操作
原子类是java1.5开始提供的一套并发解决API,作用和锁类似,目的都是为了保证在多线程并发条件下的安全问题,但是原子类类比锁来说有两个优势
- 锁的粒度更细,原子类可以将锁的粒度细化到变量级别, 但是通常锁的粒度都是好几行代码
- 效率更高,但是在线程高度竞争的情况下效率会降低
Atomic类的使用很简单,这里不再赘述,下面谈一下Atomic是如何实现的。
2.1 CAS 操作
在JDK 5之前Java语言是靠synchronized关键字保证同步的,这会导致有锁(后面的章节还会谈到锁)。
锁机制存在以下问题:
- 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
- 一个线程持有锁会导致其它所有需要此锁的线程挂起。
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。
独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
上面的乐观锁用到的机制就是CAS,Compare and Swap。
一个标准的CAS包含三个操作:
- 将要操作的内存地址M。
- 现有的变量A。
- 新的需要存储的变量B。
CAS将会先比较A和M中存储的值是否一致,一致则表示其他线程未对该变量进行修改,则将其替换为B。 否则不做任何操作。 使用CAS可以不用阻塞其他的线程,但是我们需要自己处理好当更新失败的情况下的业务逻辑处理情况。
2.2 非阻塞算法 (nonblocking algorithms)
一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。
现代的CPU提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet()
就用这些代替了锁定。
拿出AtomicInteger来研究在没有锁的情况下是如何做到数据正确性的。
private volatile int value; |
首先毫无疑问,在没有锁的机制下可能需要借助volatile原语,保证线程间的数据是可见的(共享的)。
这样才获取变量的值的时候才能直接读取。
public final int get() { |
然后来看看 ++i 是怎么做到的。
public final int incrementAndGet() { |
在这里采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。
而compareAndSet
利用JNI来完成CPU指令的操作。
public final boolean compareAndSet(int expect, int update) { |
整体的过程就是这样子的,利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。
而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。参考资料的文章中介绍了如果利用CAS构建非阻塞计数器、队列等数据结构。
CAS看起来很爽,但是会导致“ABA问题”。
CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。
AtomicStampedReference
通过引入时间戳来解决了ABA问题。每次要更新值的时候,需要额外传入oldStamp和newStamp。将对象和stamp包装成了一个Pair对象。
User user = new User("jaychou",24); |
三、ReentrantLock
在 JDK 1.5 之前共享对象的协调机制只有 synchronized 和 volatile,在 JDK 1.5 中增加了新的机制 ReentrantLock,该机制的诞生并不是为了替代 synchronized,而是在 synchronized 不适用的情况下,提供一种可以选择的高级功能。
ReentrantLock 是 Lock 的默认实现方式之一,它是基于 AQS(Abstract Queued Synchronizer,队列同步器)实现的,它默认是通过非公平锁实现的,在它的内部有一个 state 的状态字段用于表示锁是否被占用,如果是 0 则表示锁未被占用,此时线程就可以把 state 改为 1,并成功获得锁,而其他未获得锁的线程只能去排队等待获取锁资源。
ReentrantLock 中的 lock() 是通过 sync.lock() 实现的,但 Sync 类中的 lock() 是一个抽象方法,需要子类 NonfairSync 或 FairSync 去实现,NonfairSync 中的 lock() 源码如下:
final void lock() { |
FairSync 中的 lock() 源码如下:
final void lock() { |
可以看出非公平锁比公平锁只是多了一行 compareAndSetState 方法,该方法是尝试将 state 值由 0 置换为 1,如果设置成功的话,则说明当前没有其他线程持有该锁,不用再去排队了,可直接占用该锁,否则,则需要通过 acquire 方法去排队。
synchronized 和 ReentrantLock 都提供了锁的功能,具备互斥性和不可见性。在 JDK 1.5 中 synchronized 的性能远远低于 ReentrantLock,但在 JDK 1.6 之后 synchronized 的性能略低于 ReentrantLock,它的区别如下:
- synchronized 是 JVM 隐式实现的,而 ReentrantLock 是 Java 语言提供的 API;
- ReentrantLock 可设置为公平锁,而 synchronized 却不行;
- ReentrantLock 只能修饰代码块,而 synchronized 可以用于修饰方法、修饰代码块等;
- ReentrantLock 需要手动加锁和释放锁,如果忘记释放锁,则会造成资源被永久占用,而 synchronized 无需手动释放锁;
- ReentrantLock 可以知道是否成功获得了锁,而 synchronized 却不行。