原子性分析

54 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情

什么是原子性?

在数据库事务的ACID特性当中,原子性体现在当前操作包含多个数据库事务,要么这些事务全部成功,要么全部失败,不允许部分成功或部分失败的情况。在多线程环境中原子性也是一样的道理,主要指的是一个或多个指令操作在CPU执行过程中不允许被中断,否则原子性就会被破坏。举个栗子:

public class AtomicDemo {
    private volatile int i = 0;
    private void incr() {
        i++; // 自增1
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicDemo atomicDemo = new AtomicDemo();
        Thread thread1 = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                atomicDemo.incr();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                atomicDemo.incr();
            }
        });
        // 开启线程运行
        thread1.start();
        thread2.start();
        // join保证两个线程执行结束打印日志
        thread1.join();
        thread2.join();
        System.out.println("计数结果:" + atomicDemo.i);
    }
}

image.png

上述代码主要通过创建两个线程,定义一个共享变量和共享方法,两个线程同时运行并发调用共享方法对共享变量i做自增,按代码所述每个线程自增10000次,那么i的结果应该是20000,但是以上代码的执行结果跟我们预期的不一致,多运行几次结果都是小于20000,导致这个问题的原因就是原子性被破坏。

导致原子性问题的原因

线程上下文切换

CPU在运行过程中不会把所有时间完全交付给一个线程工作完再给另一个线程工作,而是会分为多个时间片,而每个时间片分配到哪个线程工作是不固定的,以此来提高CPU的执行效率。如果多个时间片分配到不同线程,那么就会出现线程上下文切换的问题,所以就可能导致原子性问题。

image.png

如上图所示时间片的切换以及线程的执行情况可以看出线程A在一个时间片中未能执行完所有指令,此时JVM将会保存程序计数器状态等信息,以便于下次获取到CPU时间片的时候再次执行未完成的指令从而恢复线程上下文。

执行指令的原子性

从上面的例子当中,incr()方法里的i++操作表面上看起来是一个完整的指令,但是通过编译后的字节码指令来看其实就不是这样的。使用idea安装插件jclasslib进行查看incr()方法中的字节码指令是怎样的:

image.png

如图我们可以看出i++操作实际上是由三个指令完成的:

  • getfield:将i变量的值从内存中加载到CPU寄存器中。
  • iadd:在寄存器中执行+1操作。
  • putfield:将寄存器中i变量的值保存到内存中。

那么以上的问题就很容易发现了,i++在字节码指令当中是分为三步的,也就是这三个步骤不是一个整体不具备原子性,那么CPU在执行的过程中就会存在中断的可能,最终将出现原子性问题。

我们再根据下图模拟以上示例两个线程并发进行i++CPU执行过程中可能出现的某种情况:

image.png

  • 线程A优先获取CPU执行权,CPU执行指令getfieidi=0从内存中加载到寄存器,然后出现线程切换,线程A保留当前线程上下文并把CPU执行权交给线程B
  • 线程B同样执行getfield执行将i=0从内存中加载到寄存器,然后再执行iadd执行进行+1的操作(此时寄存器中i的值为1),最后执行putfieldi=1保存到内存中(此时内存中i的值为1)。
  • 线程B释放了CPU资源后,线程A又重新获得CPU执行权,此时线程A先进行上下文恢复(此时线程A中寄存器i的值为0),然后执行iadd进行+1操作(此时寄存器中i的值为1),最后执行putfieldi=1保存到内存中(此时内存中i的值为1)。

由以上分析结果可以看出,两个线程同时进行了i++动作,预期内存中的i值应该为2,结果内存中i的值为1,这种情况最终就会导致i的结果小于我们的预期值。

以上都是在单核CPU下出现上下文切换的情况导致的问题,那么如果是多核CPU又会是怎样的。其实也不难理解,两个线程分别在两个CPU上并行,同时从内存中将i=0加载到寄存器,再同时做+1的操作,然后再同时将i=1写回内存中,结果内存中i的值仍然是1,还是会出现同样的问题。

原子性问题的解决方法

凡是有问题,就一定会有解决方法,那么出现原子性问题该如何解决呢?

  • 使用synchronized同步锁机制对出现并发的代码块进行加锁:
private synchronized void incr() {
	i++; // 自增1
}

如上面的例子,就是对incr()方法加synchronized同步锁防止并发问题。

  • 利用CAS机制进行数据更新,将上面的例子改造如下:
public class AtomicDemo {

    private AtomicInteger i = new AtomicInteger(0);
    private void incr() {
        i.incrementAndGet();
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicDemo atomicDemo = new AtomicDemo();
        Thread thread1 = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                atomicDemo.incr();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                atomicDemo.incr();
            }
        });
        // 开启线程运行
        thread1.start();
        thread2.start();
        // join保证两个线程执行结束打印日志
        thread1.join();
        thread2.join();
        System.out.println("计数结果:" + atomicDemo.i.get());
    }
}

将变量i改为使用AtomicInteger对象,使用i.incrementAndGet();进行自增操作,其原理就是在对象头增加一个版本的标识,在每次变更数据的时候通过跟内存中所记录的版本是否一致来决定数据是否允许更新,如果版本不一致则自旋一次重新加载内存中的数据进行计算,直到版本一直方可更新数据。

// 最终调用底层的方法对数据做比较与替换
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

更多的CAS自旋原理可参考之前写的文章:教你写一个自旋锁

总结

在多线程环境下出现多线程共享变量以及共享数据的情况并不少见,在设计之初就该慎重考虑,特别在一些缓存以及数据库共享的情况,多个业务并发处理一条数据容易出现差错,否则当测试出现问题的时候再来改造可能代价就大了。