诡异的并发之原子性

上一节阿粉我和大家一起打到了并发中的恶霸可见性,这一节我们继续讨伐三恶之一的原子性。

序、原子性的阐述

一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。

我理解是一个操作不可再分,即为原子性。而在并发编程的环境中,原子性的含义就是只要该线程开始执行这一系列操作,要么全部执行,要么全部未执行,不允许存在执行一半的情况。

我们试着从数据库事务和并发编程两个方面来进行对比:

1、在数据库中

原子性概念是这样子的:事务被当做一个不可分割的整体,包含在其中的操作要么全部执行,要么全部不执行。且事务在执行过程中如果发生错误,会被回滚到事务开始前的状态,就像这个事务没有执行一样。(也就是说:事务要么被执行,要么一个都没被执行)

2、在并发编程中

原子性概念是这样子的:

  • 第一种理解:一个线程或进程在执行过程中,没有发生上下文切换。
    • 上下文切换:指CPU从一个进程/线程切换到另外一个进程/线程(切换的前提就是获取CPU的使用权)。
  • 第二种理解:我们把一个线程中的一个或多个操作(不可分割的整体),在CPU执行过程中不被中断的特性,称为原子性。(执行过程中,一旦发生中断,就会发生上下文切换)

从上文中对原子性的描述可以看出,并发编程和数据库两者之间的原子性概念有些相似: 都是强调,一个原子操作不能被打断!

而非原子操作用图片表示就是这样子的:

线程A在执行一会儿(还没有执行完成),就出让CPU让线程B执行。这样的操作在操作系统中有很多,牺牲切换线程的极短耗时,来提高CPU的利用率,从而在整体上提高系统性能;操作系统的这种操作就被称为“时间片”切换。

一、出现原子性问题的原因

通过序中描述的原子性的概念,我们总结出了:导致共享变量在线程之间出现原子性问题的原因是上下文切换。

那么接下来,我们通过一个例子来重现原子性问题。

首先定义一个银行账户实体类:

1
2
3
4
5
6
7
8
9
10
    @Data
    @AllArgsConstructor
    public static class BankAccount {
        private long balance;

        public long deposit(long amount){
            balance = balance + amount;
            return balance;
        }
    }

然后开启多个线程对这个共享的银行账户进行存款操作,每次存款1元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.ArrayList;

/**
 * @author :mmzsblog
 * @description:并发中的原子性问题
 * @date :2020/2/25 14:05
 */
public class AtomicDemo {

    public static final int THREAD_COUNT = 100;
    static BankAccount depositAccount = new BankAccount(0);

    public static void main(String[] args) throws Exception {

        ArrayList<Thread> threads = new ArrayList<>();
        for (int i = 0; i < THREAD_COUNT; i++) {
            Thread thread = new DepositThread();
            thread.start();
            threads.add(thread);
        }

        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println("Now the balance is " + depositAccount.getBalance() + "元");
    }

    static class DepositThread extends Thread {
        @Override
        public void run() {
            for (int j = 0; j < 1000; j++) {
                depositAccount.deposit(1);   // 每次存款1元
            }
        }
    }
}

多次运行上面的程序,每次的结果几乎都不一样,偶尔能得到我们期望的结果100*1*1000=100000元,如下是我列举的几次运行结果:

出现上面情况的原因就是因为

1
balance = balance + amount;

这段代码并不是原子操作,其中的balance是一个共享变量。在多线程环境下可能会被打断。就这样原子性问题就赤裸裸的出现了。如图所示:

当然,如果balance是一个局部变量的话,即使在多线程的情况也不会出现问题(但是这个共享银行账户不适用局部变量啊,否则就不是共享变量了,哈哈,相当于废话),因为局部变量是当前线程私有的。就像图中for循环里的j变量。

但是呢,即使是共享变量,阿粉我也绝不允许这样的问题出现,所以我们需要解决它,然后更加深刻的理解并发编程中的原子性问题。

二、解决上下文切换带来的原子性问题

2.1、使用局部变量

局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就被抛弃了,局部变量和方法同生共死。而调用栈的栈帧也是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。而事实上,局部变量的确是放到了调用栈里。

正是因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。总结起来就是:没有共享,就不会出错。

但此处如果用局部变量的话,100个线程各自存1000元,最后都是从0开始存,不会累计,也就失去了原本想要展现的结果。故此方法不可行。

正如此处使用单线程也能保证原子性一样,因为不适合当前场景,因此并不能解决问题。

2.2、自带原子性保证

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作。

比如下面这几行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 原子性
a = true;  

// 原子性
a = 5;     

// 非原子性,分两步完成:
//          第1步读取b的值
//          第2步将b赋值给a
a = b;     

// 非原子性,分三步完成:
//          第1步读取b的值
//          第2步将b值加2
//          第3步将结果赋值给a
a = b + 2; 

// 非原子性,分三步完成:
//          第1步读取a的值
//          第2步将a值加1
//          第3步将结果赋值给a
a ++;      

2.3、synchronized

把所有java代码都弄成原子性那肯定是不可能的,计算机一个时间内能处理的东西永远是有限的。所以当没法达到原子性时,我们就必须使用一种策略去让这个过程看上去是符合原子性的。因此就有了synchronized。

synchronized既可以保证操作的可见性,也可以保证操作结果的原子性。

某个对象实例内,synchronized aMethod(){}可以防止多个线程同时访问这个对象的synchronized方法。

如果一个对象有多个synchronized方法,只要一个线程访问了其中的一个synchronized方法,其它线程不能同时访问这个对象中任何一个synchronized方法。

所以,此处我们只需要将存款的方法设置成synchronized的就能保证原子性了。

1
2
3
4
5
6
 private volatile long balance;

 public synchronized long deposit(long amount){
     balance = balance + amount; //1
     return balance;
 }

加了synchronized后,当一个线程没执行完加了synchronized的deposit这个方法前,其他线程是不能执行这段被synchronized修饰的代码的。因此,即使在执行代码行1的时候被中断了,其它线程也不能访问变量balance;所以从宏观上来看的话,最终的结果是保证了正确性。但中间的操作是否被中断,我们并不知道。如需了解详情,可以看看CAS操作。

PS:对于上面的变量balance大家可能会有点疑惑:变量balance为什么还要加上volatile关键字?其实这边加上volatile关键字的目的是为了保证balance变量的可见性,保证进入synchronized代码块每次都会去从主内存中读取最新值。

故此,此处的

1
 private volatile long balance;

也可以换成synchronized修饰

1
 private synchronized long balance;

因为而这都能保证可见性,我们在第一篇文章诡异的并发之可见性中已经介绍过了。

2.4、Lock锁

1
2
3
4
5
6
7
8
9
public long deposit(long amount) {
    readWriteLock.writeLock().lock();
    try {
        balance = balance + amount;
        return balance;
    } finally {
        readWriteLock.writeLock().unlock();
    }
}

Lock锁保证原子性的原理和synchronized类似,这边不进行赘述了。

可能有的读者会好奇,Lock锁这里有释放锁的操作,而synchronized好像没有。其实,Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。

2.5、原子操作类型

如果要用原子类定义属性来保证结果的正确性,则需要对实体类作如下修改:

1
2
3
4
5
6
7
8
9
    @Data
    @AllArgsConstructor
    public static class BankAccount {
        private AtomicLong balance;

        public long deposit(long amount) {
            return balance.addAndGet(amount);
        }
    }

JDK提供了很多原子操作类来保证操作的原子性。比如最常见的基本类型:

1
2
3
4
AtomicBoolean
AtomicLong
AtomicDouble
AtomicInteger

这些原子操作类的底层是使用CAS机制的,这个机制保证了整个赋值操作是原子的不能被打断的,从而保证了最终结果的正确性。

和synchronized相比,原子操作类型相当于是从微观上保证原子性,而synchronized是从宏观上保证原子性。

上面的2.5解决方案中,每个小操作都是原子性的,比如AtomicLong这些原子类的修改操作,它们本身的crud操作是原子的。

那么,仅仅是将每个小操作都符合原子性是不是代表了这整个构成是符合原子性了呢?

显然不是。

它仍然会产生线程安全问题,比如一个方法的整个过程是读取A-读取B-修改A-修改B-写入A-写入B;那么,如果在修改A完成以后,失去操作原子性,此时线程B却开始执行读取B操作,此时就会出现原子性问题。

总之不要以为使用了线程安全类,你的所有代码就都是线程安全的!这总归都要从审查你代码的整体原子性出发。就比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @NotThreadSafe
    public class UnsafeFactorizer implements Servlet {

        private final AtomicReference<BigInteger> lastNum = new AtomicReference<BigInteger>();
        private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

        @Override
        public void service(ServletRequest request, ServletResponse response) {
            BigInteger tmp = extractFromRequest(request);
            if (tmp.equals(lastNum.get())) {
                System.out.println(lastFactors.get());
            } else {
                BigInteger[] factors = factor(tmp);
                lastNum.set(tmp);
                lastFactors.set(factors);
                System.out.println(factors);
            }
        }
    }

虽然它全部用了原子类来进行操作,但是各个操作之间不是原子性的。也就是说:比如线程A在执行else语句里的lastNumber.set(tmp)完后,也许其他线程执行了if语句里的lastFactorys.get()方法,随后线程A才继续执行lastFactors.set(factors)方法更新factors

从这个逻辑过程中,线程安全问题就已经发生了。

它破坏了方法的读取A-读取B-修改A-修改B-写入A-写入B这一整体过程,在写入A完成以后其他线程去执行了读取B,就导致了读取到的B值不是写入后的B值。就这样原子性就出现了。

好了,以上内容就是我对并法中的原子性的一点理解与总结了,通过这两篇文章我们也就大致掌握了并发中常见的可见性、原子性问题以及它们常见的解决方案。

最后

贴一段经常看到的原子性实例问题。

:常听人说,在32位的机器上对long型变量进行加减操作存在并发隐患,到底是不是这样呢?

:在32位的机器上对long型变量进行加减操作存在并发隐患的说法是正确的。

原因就是:线程切换带来的原子性问题。

非volatile类型的long和double型变量是8字节64位的,32位机器读或写这个变量时得把人家咔嚓分成两个32位操作,可能一个线程读了某个值的高32位,低32位已经被另一个线程改了。所以官方推荐最好把long\double 变量声明为volatile或是同步加锁synchronize以避免并发问题。

参考文章:

  • 1、极客时间的Java并发编程实战
  • 2、https://juejin.im/post/5d52abd1e51d4561e6237124
  • 3、https://www.cnblogs.com/54chensongxia/p/12073428.html
  • 4、https://www.dazhuanlan.com/2019/10/04/5d972ff1e314a/
Java Geek Tech wechat
欢迎订阅 Java 极客技术,这里分享关于 Java 的一切。