线程安全之原子性

412 阅读11分钟

前言

上一篇文章写到JMM规范描述了多线程程序的语义以及多线程程序会出现各种各样的灵异事件

在编写多线程的时候如何避免掉这些灵异事件的发生,能让多程序程序更加符合程序员的意愿去执行

今天我们就围绕着原子性这个概念来聊一聊

要玩好多线程得先做好两件事:

  • 控制线程数量,不要让线程无限膨胀
  • 解决线程安全问题

下面便以一个代码示例来正式进入原子性话题

/**
 * <p>
 * 原子性示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {

    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count++;
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }).start();
        }

        TimeUnit.SECONDS.sleep(1);
        System.out.println(count);
    }
}

这个代码挺简单的,开启十个线程每个线程循环1000次.对volatile的全局变量count进行++操作

从逻辑上来说,正确的结果应该是10*1000=10000

但是结果偏偏却不是10000,不是说volatile可以保证线程可见性吗?count++不是一段代码而已嘛?为什么还会计算不正确

我们可以通过javap命令来反编译一下这个class文件,看看count++是怎么操作的,main函数指令如下

 private static void lambda$main$0();
    descriptor: ()V
    flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
    Code:
      stack=3, locals=1, args_size=0
         0: iconst_0
         1: istore_0
         2: iload_0
         3: sipush        1000
         6: if_icmpge     23
         9: getstatic     #9                  // Field count:I
        12: iconst_1
        13: iadd
        14: putstatic     #9                  // Field count:I
        17: iinc          0, 1
        20: goto          2
        23: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
        26: new           #11                 // class java/lang/StringBuilder
        29: dup
        30: invokespecial #12                 // Method java/lang/StringBuilder."<init>":()V
        33: invokestatic  #13                 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
        36: invokevirtual #14                 // Method java/lang/Thread.getName:()Ljava/lang/String;
        39: invokevirtual #15                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        42: ldc           #16                 // String 执行结束
        44: invokevirtual #15                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        47: invokevirtual #17                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        50: invokevirtual #18                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        53: return

count++操作主要在第9行到第14行的指令

9: getstatic     #9                  // Field count:I
12: iconst_1
13: iadd
14: putstatic     #9                  // Field count:I

第9行: 获取堆中的count值放入该栈帧的操作数栈中

第12行: 当int取值1~5时JVM采用iconst指令将常量压入栈中,对应到iconst_1指令就是将1压入栈中

第13行: 计算i的也就是count+1

第14行: 将计算count的结果写到堆内存中

从字节码指令看出,i++实际上是经过3个主要步骤

  • 从堆内存中获取到i的值并且复制一份到当前线程的操作数栈中
  • 从当前线程的操作数栈中去计算count的值
  • 计算后结果写回到堆内存中的i去

那么基本上可以确定不安全的点在于每个线程预先保留好从堆内存中获取到的count值到操作数栈(线程临时存储区)

然后在操作数栈中进行计算,将计算后的结果再写回到堆内存中

即是说由于volatile关键字可以保证可见性,所以当时获取到的count确实是最新的值

但这并不代表线程1在获取或是在计算过程中其他线程不能操作了

假设说线程1和线程2同时读取到的count值为0

线程1计算后的count值为1

线程2计算后的count值为1

这时候无论是线程1还是线程2都会往堆内存中写入count=1的指令

所以才会导致线程不安全的问题

既然知道了这个操作非原子性操作,会导致线程不安全.那么我们接下来就探讨一下有哪些解决方案

synchronized

既然多个操作在多线程程序运行下不能够保证本次操作的原子性

那么第一时间浮现在脑海里面的解决方式就是加锁

对于JVM而言使用起来最简单的加锁方式就是synchronized关键字

将代码示例改造为使用synchronized关键字

/**
 * <p>
 * synchronized示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {

    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    synchronized (Demo.class){
                        count++;
                    }
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }).start();
        }

        TimeUnit.SECONDS.sleep(1);
        System.out.println(count);
    }
}

执行结果是正确的,前面说到了因为synchronized关键字属于jvm级别的锁,既然是锁就能保证多线程操作同步

但是也会带来一个恶心的问题就是每次只能是单个线程在操作,这样对于多线程程序是不太友好的

初衷是希望多线程并行让程序运行效率高一些,现在却强行加把锁改成了多线程串行

Lock接口

Lock接口是jdk1.5提供的描述了一把锁应该具备的行为,在工作中用得比较多的实现类是ReentrantLock

/**
 * <p>
 * ReentrantLock示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {

    private static volatile int count = 0;
    private static Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    lock.lock();
                    try {
                        count++;
                    } finally {
                        lock.unlock();
                    }
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }).start();
        }

        TimeUnit.SECONDS.sleep(1);
        System.out.println(count);
    }
}

可以看到程序的执行结果也是正确的.因为它和synchronized的作用是相同的,两者都是锁

ReentrantLock属于JDK提供的工具而synchronized则是jvm提供的关键字

两者在功能上没什么区别,但是它们的实现方式是不一样的.至于怎么不一样本次我们不做重点展开

ReentrantLock也存在和synchronized相同的问题,将多线程并行转成串行

只要是锁就会出现这个问题,因为在多线程写的情况下为了保证操作的原子性只能变成串行

cas

cas全称Compare and swap既比较和交换.属于硬件同步原语,处理器提供了基本内存操作的原子性保证

cas操作需要输入两个数值一个旧值A(期望操作前的值)和一个新值B

在操作期间先对旧值进行比较如果没有发生变化才交换新值,发生了变化则不交换

Java中的sun.misc.Unsafe类提供了compareAndSwapInt()和compareAndSwapIong()等几个方法实现cas操作

unsafe

咋们先来瞧一瞧Unsafe类

Unsafe类的构造函数是私有的,在类初始化的时候执行静态代码块

实例化一个Unsafe对象并将theUnsafe引用指向该Unsafe对象

private static final Unsafe theUnsafe;

static {
    registerNatives();
    Reflection.registerMethodsToFilter(Unsafe.class, new String[]{"getUnsafe"});
    theUnsafe = new Unsafe();
}

可以看得出来Unsafe类的设计者将它设计成了单例模式(饿汉式)

并提供了一个getUnsafe方法用于获取Unsafe类的单例实例对象

@CallerSensitive
public static Unsafe getUnsafe() {
    Class var0 = Reflection.getCallerClass();
    if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
        throw new SecurityException("Unsafe");
    } else {
        return theUnsafe;
    }
}

这个方法有点意思,它说调用者所在的class类必须是核心类库加载器加载的否则将抛出安全相关异常

我们知道核心类库加载器是用来加载JDK核心类库rt.jar,比如String、Object这些jdk提供的类就是由这个加载器去加载

所以对应到的意思就是这个方法只能是jdk自己才能调用,java应用开发人员调用会抛出异常

但是java又给我们提供了反射机制,可以通过一个类的描述来获取这个类的所有信息

那么就可以通过反射的机制去获取到theUnsafe属性了

Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
unsafe = (Unsafe) unsafeField.get(null);

我们再继续接着往下看这个类提供的compareAndSwapInt针对int的cas操作

可以看到提供了三个cas操作的方法

compareAndSwapInt方法是对int的cas操作

compareAndSwapLong方法是对long的cas操作

compareAndSwapObject方法是对对象的cas操作

这些cas方法都是native方法,也就是说具体是由c++动态链接库实现的

以compareAndSwapInt方法为例,说一下这些方法的参数和返回值分别代表什么意思

 /**
     * 对int的cas操作方法注释
     *
     * @param var1 操作值所在的实例对象(如果是类变量则是类)
     * @param var2 操作值的偏移量(理解为该属性所在系统内存的具体位置)
     *             静态变量获取方式: unsafe.staticFieldOffset(Demo.class.getDeclaredField("count"));
     *             实例变量获取方式: unsafe.objectFieldOffset(Demo.class.getDeclaredField("count"));
     * @param var4 期望操作前的值
     * @param var5 期望操作后的值
     * @return 操作成功返回true, 失败则返回false
     */
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

看完Unsafe类的cas操作以后就可以来使用unsafe来将之前的程序改造成cas操作,在保证原子性的前提下让多线程程序并行执行

/**
 * <p>
 * CAS示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {

    private static volatile int count = 0;
    private static volatile long countOffset = 0;


    private static Unsafe unsafe = null;

    static {
        try {
            // 由于Unsafe类里面大多是native方法,直接可以使用系统内存.所以java不允许开发人员使用这个类
            // 但是java又提供了反射的机制,我们可以通过反射的手段去获取这个类的单实例
            Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
            unsafeField.setAccessible(true);
            unsafe = (Unsafe) unsafeField.get(null);

            // 直接使用的系统内存,想要操作这个变量需要获取到这个变量在内存中的偏移量.Unsafe也提供了这个方法
            countOffset = unsafe.objectFieldOffset(Demo.class.getDeclaredField("count"));
            countOffset = unsafe.staticFieldOffset(Demo.class.getDeclaredField("count"));
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    // 1 根据class类和count的偏移量获取count的最新值
                    // 2 然后调用compareAndSwapInt方法进行cas操作
                    // 3 可能会操作失败,如果失败则回到第一步重新执行.直到cas操作成功为止方可退出
                    int newestCount;
                    do {
                        newestCount = unsafe.getIntVolatile(Demo.class, countOffset);
                    } while (!unsafe.compareAndSwapInt(Demo.class, countOffset, newestCount, newestCount + 1));
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }).start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println(count);
    }
}

这个unsafe实现的cas示例,可以看得到运行结果是对的

但是发现如果通过unsafe实现cas操作需要写那么多代码,关注那么多cas之外的东西这显然不符合面向对象的编程思想

回想到unsafe类设计之初就没打算让java开发人员去使用,因为unsafe直接操作的是计算机,所以jdk不建议直接使用

但是它也对应的也提供了一套cas操作的api,存放在java.util.concurrent.atomic包下

这个包下的原子性保证底层也是通过unsafe实现的,它们做了更好的封装,开发人员只需要关注如何使用就好了

atomic

JUC包内的原子操作封装类

作用
AtomicBoolean原子更新布尔类型
AtomicInteger原子更新整形
AtomicLong原子更新长整形
AtomicReference原子更新引用类型
AtomicIntegerArray原子更新整形数组里的元素
AtomicLongArray原子更新长整形数组里的元素
AtomicReferenceArray原子更新引用类型数组里的元素
AtomicIntegerFieldUpdater原子更新整形字段的更新器
AtomicLongFieldUpdater原子更新长整形字段的更新器
AtomicReferenceFieldUpdater原子更新引用类型字段的更新器

这些原子操作工具类大致分为三种类型 :Atomic类型、 Atomic类型数组、 Atomic类型字段更新器

对于每种类型的API使用都是一样的,所以同理可得本文将会分别操作这三种类型中的一个工具类作为使用案例参考

Atomic类型

对于Atomic类型的操作将以AtomicInteger为例

/**
 * <p>
 * AtomicInteger示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {

    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count.addAndGet(1);
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }).start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println(count.get());
    }
}

AtomicInteger本身也是通过unsafe去实现的cas操作,所以结果也一定是正确的

Atomic类型数组

对于Atomic类型数组的操作将以AtomicIntegerArray为例

/**
 * <p>
 * AtomicIntegerArray使用示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {

    public static void main(String[] args) throws InterruptedException {
		// 定义整形数组长度
        AtomicIntegerArray array = new AtomicIntegerArray(5);
		// 将数组下标为0设置为100
        array.set(0, 100);
		// 将数组下标为0的进行cas操作
        array.compareAndSet(0, 100, 200);
		// 获取数组下标为0的值
        System.out.println(array.get(0));
    }
}

AtomicIntegerArra用的是普通整形数组而非AtomicInteger数组

compareAndSet操作是对整个数组进行cas操作而非针对每个下标进行cas操作

Atomic类型字段更新器

对于Atomic类型字段更新器的操作将以AtomicIntegerFieldUpdater为例

/**
 * <p>
 * AtomicIntegerFieldUpdater使用示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {


    static class User {
        public long id;
        public volatile int age;


        public User(long id, int age) {
            this.id = id;
            this.age = age;
        }

        public Long getId() {
            return id;
        }

        public void setId(long id) {
            this.id = id;
        }

        public Integer getAge() {
            return age;
        }

        public void setAge(int age) {
            this.age = age;
        }

        @Override
        public String toString() {
            return "User{" +
                    "id=" + id +
                    ", age=" + age +
                    '}';
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 指定要对哪个类的哪个属性进行cas操作
        AtomicIntegerFieldUpdater<User> fieldUpdater = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");

        User user = new User(1L, 18);
        // 如果该对象的年龄为18岁将该对象的年龄修改为20岁
        fieldUpdater.compareAndSet(user, 18, 20);
        // 将该对象的年龄加1岁
        fieldUpdater.addAndGet(user, 1);

        System.out.println(user);
    }
}

有个注意的地方是修改的字段必须用volatile修饰

cas的缺点

对于cas操作的好处总结起来就是一句话保证原子操作的前提下还能让多线程并行操作,但是它也存在一些弊端

技术没有银弹得根据不同应用场景选择合适的解决方案,下面就来聊一下cas操作的弊端

局限性

对于jdk提供的所有cas操作工具类都只能针对单个变量的操作,不能用于多个变量来实现原子操作

就比如说字段更新器每次就只能操作一个对象里面的一个字段

哈哈,其实我也不知道有啥比较好的解决方案,因为这个也算是cas操作本身的短板

目前想到的是如果需要多个变量来实现原子操作那就多定义几个cas工具类来一起进行操作

嗅探机制和总线风暴

嗅探

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了

当处理器发现自己缓存行对应的内存地址被修改就会将当前处理器的缓存行设置成无效状态

当处理器对这个数据进行修改操作的时候会重新从系统内存中把数据读到处理器缓存里

总线风暴

由于Volatile的MESI缓存一致性协议需要不断的从主内存嗅探和cas不断循环

无效交互会导致总线带宽达到峰值

简单来说循环+cas自旋的实现让所有线程都处于高频运行

争抢cpu执行时间的状态,如果操作长时间不成功会带来很大的cpu资源损耗

所以cas不合适应用在高并发的场景下

jdk1.8中更新了对计数器增强版在高并发下性能更好

说明
DoubleAdder浮点型计数器
LongAdder整形计数器
DoubleAccumulator浮点型更新器
LongAccumulator整形更新器

它的原理是将一个值分成多个操作单元,不同线程更新不同的单元,只有需要汇总的时候才计算所有单元的操作

适用于高并发频繁更新不太频繁读取地场景

LongAdder使用

/**
 * <p>
 * LongAdder使用示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {
    
    private static LongAdder count = new LongAdder();
    
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count.add(1);
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }).start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println(count.sum());
    }
}

LongAccumulator使用

/**
 * <p>
 * LongAccumulator使用示例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {
    /**
     * 构造函数传入自定义规则进行cas操作
     * X表示当前变量的值
     * Y表示当前accumulate方法传入的参数
     * identity表示初始值
     */
    private static LongAccumulator count = new LongAccumulator((x, y) -> x + y, 0);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count.accumulate(1);
                }
                System.out.println(Thread.currentThread().getName() + "执行结束");
            }).start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println(count.get());
    }
}

尽管是在jdk1.8中升级了cas操作的增强版但是它本质还是一个cas操作

只是将一个值分成多个操作单元,目的是让多线程争抢同一个资源的概率降低,提高cas操作的成功率

所以在高并发很的情况下还是推荐使用锁的方式来实现原子性操作

ABA

ABA问题涉及到的是一个版本的问题,因为cas操作只看结果不看过程,下面将用一个代码案例来进行说明

/**
 * <p>
 * ABA问题案例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {
    private static AtomicInteger count = new AtomicInteger(0);
    
 	/**
     * 1.线程1和线程2同时读取到count=0
     * 2.线程1和线程2都要执行cas(0,1)操作
     * 3.线程1执行成功了接着立刻执行cas(1,0)操作也成功了
     * 4.线程2紧接着执行cas(0,1)成功
     */
    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> {
            count.compareAndSet(0, 1);
            count.compareAndSet(1, 0);
        }, "线程1").start();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
                count.compareAndSet(0, 1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "线程2").start();


        TimeUnit.SECONDS.sleep(2);
        System.out.println(count.get());
    }
}

从严谨性的角度来说结果应该是0.可是结果1的话好像本次的cas操作成功者是线程2

问题点就出现在这个cas操作只在乎结果不在乎过程

解决办法

AtomicStampedReference 原子更新带有版本号的引用类型

/**
 * <p>
 * AtomicStampedReference使用实例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {
    private static AtomicStampedReference<Integer> count = new AtomicStampedReference(0, 1);


    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("线程1:cas(0,1)操作状态" + count.compareAndSet(0, 1, 1, 1 + 1));
            System.out.println("线程1:cas(1,0)操作状态" + count.compareAndSet(1, 0, 2, 2 + 1));
        }, "线程1").start();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
                System.out.println("线程2:cas(0,1)操作状态" + count.compareAndSet(0, 1, 1, 1 + 1));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "线程2").start();


        TimeUnit.SECONDS.sleep(2);
        System.out.println(count.getReference());
    }
}

AtomicMarkableReference 原子更新带有标记位的引用类型

/**
 * <p>
 * AtomicMarkableReference使用实例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/29 0029 15:17
 */
public class Demo {
    private static AtomicMarkableReference<Integer> count = new AtomicMarkableReference(0, true);


    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("线程1:cas(0,1)操作状态" +  count.compareAndSet(0, 1, true, false));
            System.out.println("线程1:cas(1,0)操作状态" +  count.compareAndSet(1, 0, false, false));
        }, "线程1").start();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
                System.out.println("线程2:cas(0,1)操作状态" + count.compareAndSet(0, 1, true, false));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "线程2").start();


        TimeUnit.SECONDS.sleep(2);
        System.out.println(count.getReference());
    }
}

线程安全概念

竞态条件

如果程序运行顺序的改变会影响最终的结果就存在竞态条件

大多数竞态条件的本质就是基于某种可能失效的观察结果来做出判断或执行某个计算

临界区

存在竞态条件的代码区区域称为临界区

栈封闭

栈封闭时不存在线程之间共享的变量都是线程安全的

局部对象引用

局部对象引用本身不共享但是引用的对象存储在共享堆中

如果方法内创建对象,只是在方法中传递,并且不对其他线程可用,那么也是线程安全的

不可变的共享对象

不可变的共享对象来保证对象在线程间共享时不会被修改,从而实现线程安全

实例被创建时value变量就不能再被修改,这就是不可变性

总结

回顾一下今天聊的重点内容

  • count++这个操作不是原子性操作

  • 原子性操作的概念

  • cas机制概念以及利用cas机制实现原子性

  • AtomicInteger等Atomic类底层就是利用了unsafe提供的cas机制实现

  • JDK提出了高并发场景性能更好的累加计数器

  • 带有版本号的引用类型可以实现版本号锁

  • 带有标记位的引用类型可以实现标记位锁

  • 线程安全的相关

那么关于线程安全之原子性的话题聊到这里就结束了,谢谢大家观看