接上文,在并发情况下,使用了volatile关键字,能否保证多线程情况下结果的准确性?答案是不能,验证如下:

//自定义的类
public static class MyTest {
//类的内部成员变量num
public volatile int num = 0;

public void numPlusPlus() {
num++;
}
}

主方法为

public static void main(String[] args) {
MyTest myTest = new MyTest();
/**
* 10个线程创建出来,每个线程执行2000次num++操作
* 我们知道,在字节码及底层,i++被抽象为三个操作
* 即先取值,再自加,再赋值操作
*/
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
for (int j = 0; j < 2000; j++) {
myTest.numPlusPlus();
}
}, "Thread" + i).start();
}

//这里规定线程数大于2,一般有GC线程以及main主线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t finally num value is " + myTest.num);
}

代码如上所示,如果volatile保证原子性,那么10个线程分别执行自加2000次操作,那么最终结果一定是20000,但是执行三次结果如下。

//第一次
main finally num value is 19003
//第二次
main finally num value is 18694
//第三次
main finally num value is 19552

可以发现,我们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 {
//在方法前面加上synchronized关键字表示作用于该方法
//需要注意方法有两种,一种静态方法,一种非静态方法
//两者区别在于,当修饰静态时候,大家都调用的是同一个。当修饰非静态方法时候,调用的是每个对象自己的那个方法,因为非静态域或方法是每个对象各自都有一份的,静态方法是所有对象公有的。
synchronized public static void mB(String value) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
synchronized public void mC(String value) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
}

用于代码块

class A {
public static void test() {
//修饰代码块的情况也有两种,这里表示对类进行同步
synchronized (A.class) {
System.out.println("haha");
}
}
public void test2() {
//这里表示对当前对象进行同步,两者区别看下文
synchronized (this) {
System.out.println("haha");
}
}
}

1.2 synchronized锁的种类

类锁

类锁,是用来锁类的,我们知道一个类的所有对象共享一个class对象,共享一组静态方法,类锁的作用就是使持有者可以同步地调用静态方法。当synchronized指定修饰静态方法或者class对象的时候,拿到的就是类锁,类锁是所有对象共同争抢一把。

//B中有两个方法mB和mC
//mB是synchronized修饰静态方法,拿到类锁
//mC是synchronized修饰非静态方法,拿到的也是类锁
class B {
synchronized public static void mB(String value) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
public void mC(String value) {
synchronized (B.class) {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
}
}

对象锁

对象锁,是用来对象的,虚拟机为每个的非静态方法和非静态域都分配了自己的空间,不像静态方法和静态域,是所有对象共用一组。
所以synchronized修饰非静态方法或者this的时候拿到的就是对象锁,对象锁是每个对象各有一把的,即同一个类如果有两个对象。

//类C中有两个方法mB和mC
//mB是synchronized非静态方法,拿到对象锁
//mC是synchronized修饰this,拿到的也是对象锁
class C {
synchronized publi void mB(String value) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
public void mC(String value) {
synchronized (this) {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
}
}

1.3 对象锁和类锁的使用

对象锁

下例中,两个线程调用同一个对象b的mB方法。最终结果是输出了1000次“1”之后输出了1000次“2”。可见两个线程对此方法的调用实现了同步。

class B {
//修饰非静态方法拿到对象锁
synchronized public void mB(String name) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.print(name);
}
}
//修饰this拿到对象锁
public void mB2(String name) throws InterruptedException {
synchronized(this) {
for (int i = 0; i < 1000; i++) {
System.out.print(name);
}
}
}
}

public class test {
public static void main(String[] args) {

B b = new B();
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
try {
//线程1的调用处
b.mB("1");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
try {
//线程2的调用处
b.mB2("2");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread1.start();
thread2.start();
}
}

类锁

下面代码中对静态方法mA和mC的调用实现了同步,结果没有交替输出1和2,而是一次性输出完成后再输出的另一种

class B {
//修饰静态方法,调用取得类锁
synchronized public static void mB(String value) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
//修饰class对象,调用取得静类锁
public static void mC(String value) {
synchronized (B.class) {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
}
public static void main(String[] args) {

Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
B.mB("1");
} catch (InterruptedException e) {
e.printStackTrace();
}

}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
B.mC("2");
}
});
}
}

类锁和对象锁同时存在

同时存在的情况下,两者做不到同步。类锁和对象锁是两种锁。下述情况,类B的静态方法和代码块功能都是打印100个value值,但是静态方法是类锁,而代码块锁this,是对象锁。所以代码块和静态方法交替执行、交替打印,大家可复制代码自行验证。

class B {
//静态方法,上类锁,函数功能为连续打印1000个value值,调用时会传1,所以会打印1000个1
synchronized public static void mB(String value) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
public void mC(String value) {
//修饰this上对象锁,函数功能也是连续打印1000个value值,调用时会传2,所以会打印1000个2
synchronized (this) {
for (int i = 0; i < 1000; i++) {
System.out.print(value);
}
}
}

public static void main(String[] args) {

Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
B.mB("1");
} catch (InterruptedException e) {
e.printStackTrace();
}

}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
new B().mC("2");
}
});
thread.start();
thread2.start();
}
}

二、原子操作

原子类是java1.5开始提供的一套并发解决API,作用和锁类似,目的都是为了保证在多线程并发条件下的安全问题,但是原子类类比锁来说有两个优势

  • 锁的粒度更细,原子类可以将锁的粒度细化到变量级别, 但是通常锁的粒度都是好几行代码
  • 效率更高,但是在线程高度竞争的情况下效率会降低

image-20220519144115835

Atomic类的使用很简单,这里不再赘述,下面谈一下Atomic是如何实现的。

2.1 CAS 操作

在JDK 5之前Java语言是靠synchronized关键字保证同步的,这会导致有锁(后面的章节还会谈到锁)。

锁机制存在以下问题:

  1. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  2. 一个线程持有锁会导致其它所有需要此锁的线程挂起。
  3. 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

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() {
return value;
}

然后来看看 ++i 是怎么做到的。

public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}

在这里采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。

compareAndSet利用JNI来完成CPU指令的操作。

public final boolean compareAndSet(int expect, int update) {   
return unsafe.compareAndSwapInt(this, valueOffset, expect, 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);
AtomicStampedReference<User> userAtomicStampedReference = new AtomicStampedReference<>(user,1);

while (true){
User user1 = new User("jay",222);
int oldStamp1 = userAtomicStampedReference.getStamp();
int[] stamp = new int[1];
User oldUser = userAtomicStampedReference.get(stamp);
boolean flag = userAtomicStampedReference.compareAndSet(oldUser,user1,stamp[0],stamp[0]+1);
if (flag){
break;
}
}

int[] s = new int[1];
System.out.println(userAtomicStampedReference.get(s));
System.out.println(s[0]);

三、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() {
if (compareAndSetState(0, 1))
// 将当前线程设置为此锁的持有者
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

FairSync 中的 lock() 源码如下:

final void lock() {
acquire(1);
}

可以看出非公平锁比公平锁只是多了一行 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 却不行。