9. 线程安全之原子操作

前言:上一节学习了JMM、Happen Before、可见性等等这种概念,基本都是来源于JDK的官方网站中,上面有所说明了,能够追根溯源才能够跟上技术演进。

9.0 来自JDK官方的多线程描述

JDK官方对于多线程相关理论的说明:

https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html

里面有介绍同步关键字、原子性、死锁等等概念。(源于官方才是原汁原味)

9.1 原子性的引入

9.1.1 多线程引起的问题

下面跟上节一样,我们先用一个简单的程序来说明,并发产生的问题

package szu.vander.lock;

import java.util.concurrent.TimeUnit;

/**
 * @author : Vander
 * @date :   2019/08/7
 * @description :
 */
public class WrongLockDemo {

    volatile int i = 0;

    public void add() {
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        WrongLockDemo lockDemo = new WrongLockDemo();

        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    lockDemo.add();
                }
            }).start();
        }
        // 让主线程Sleep 2秒,保证有足够的时间运行完
        TimeUnit.SECONDS.sleep(2);
        System.out.println(lockDemo.i);
    }

}

运行结果:发现并不是等于20000的,而且远远不够

我们先来简单分析一下,首先i是加了volatile的,从上一节学习中知道了,加了此关键字能够保证读取的时候是主内存的值,所以线程1对i进行了加1操作肯定能被线程2发现的。第二个就是与i相关的操作不会进行重排序。那么此处究竟是什么导致了没加成功呢。

9.1.2 解析源码

我们可以使用javap -v反编码WrongLockDemo.class


我们发现i++其实是由好几个步骤组成的,首先是获取到i的值,然后跟变量1相加,在把相加后的结果放回去。
说白了就是三个步骤:
1)加载i
2)执行+1
3)赋值i
所以就会出现以下的情况,导致最后累加的结果不正确:

9.1.3 相关概念

线程安全
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。(说白了就是在多线程的情况下,能得到你想要的。)

竞态条件与临界区

多个线程访问了相同的资源,向这些资源做了写操作时,对执行顺序有要求。
临界区:incr方法内部就是临界区域,关键部分代码的多线程并发执行,会对执行结果产生影响。(简单的说,就是某个方法在单线程运行没问题,多线程运行会有问题,而这个方法就是临界区)
竞态条件:可能发生在临界区域内的特殊条件。多线程执行incr方法中的i++关键代码时,产生了竞态条件。(引发关键问题的关键代码)

共享资源
如果一段代码是线程安全的,则它不包含竞态条件。只有当多个线程更新共享资源时,才会发生竞态条件。
栈封闭时,不会在线程之间共享的变量,都是线程安全的。
局部对象引用本身不共享,但是引用的对象存储在共享堆中。如果方法内创建的对象,只是在方法中传递,并且不对其他线程可用,那么也是线程安全的。
局部变量只能由一个线程执行,局部变量是存放在线程栈的栈帧里的,不存在变量共享的问题,所以不会有资源竞争的可能。

/** 
 * 像以下代码也是线程安全的
 */
public vold someMethod() {
      LocalObject localObject = new LocalObject();
      localOblect.callMethod();
      method2(localObject);
}
public void method2(LocalObject localObject){
    localObject.setValue("value");
}

判定资源是否线程安全的规则:如果创建、使用和处理资源,永远不会逃脱单个线程的控制,该资源的使用时线程安全的。

不可变对象

public class Demo {
    private int value = 0;
    
    public Demo(int value){
        this.value = value;
    }
    
    public int getValue(){
        return this.value;
    }
    
}

以上代码没有提供set方法,一旦构造完成,该对象中的value属性就不会再改变,这种变量称为不可变对象
创建不可变的共享对象来保证对象在线程间共享时不会被修改,从而实现线程安全。实例被创建,value变量就不能再被修改,这就是不可变性。

原子操作的定义
原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分(不可中断性)
将整个操作视为一个整体,资源在该次操作中保持一致,这是原子性的核心特征。
上述的incr()方法中的i++,实际上执行的是三个步骤:1)加载 2)计算 3)赋值
也就是说,这三个步骤是不可中断的,否则原子操作就不成立了。

9.2 原子性的实现方式

9.2.1 硬件同步原语—Unsafe类

CAS机制
Compare and swap比较和替换,属于硬件同步原语,处理器提供了基本内存操作的原子性保证。CAS操作需要输入两个数值,一个旧值A(期望操作前的值)和一个新值B,在操作
期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
sun.mise.Unsafe
Java中的sun.mise.Unsafe类,提供了compareAndSwapInt()和compareAndSwapLong()等几个方法实现CAS。

在硬件底层中,对同一个内存地址同一时刻只能有一个线程去修改,假设线程1,2都先读取到了A=1,然后线程1先去修改这个内存的值,改成功了,然后线程B也来改成2,结果发现原来的值已经改变了,所以不进行+1操作了。

示例:使用Unsafe硬件原语实现自增的原子性

package szu.vander.atomicity;

import sun.misc.Unsafe;

import java.lang.reflect.Field;
import java.util.concurrent.TimeUnit;

/**
 * @author : Vander
 * @date :   2019/11/20
 * @description : Unsafe中的方法都是本地方法,均由C实现
 */
public class UnsafeLockDemo {

    volatile int num;

    private static Unsafe unsafe;

    private static long valueOffset;// 属性偏移量,用于JVM去定位属性在内存中的地址

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

            // CAS 硬件原语 ---java语言无法直接改内存,曲线通过对象及属性的定位方式
            valueOffset = unsafe.objectFieldOffset(UnsafeLockDemo.class.getDeclaredField("num"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void add() {
        boolean result;
        do {
            // 1)获取当前值
            int currentNum = unsafe.getIntVolatile(this, valueOffset);
            // 2)计算值
            int nextNum = currentNum + 1;
            // 3)写入值,若num的值被其它线程修改了,则操作不成功
            result = unsafe.compareAndSwapInt(this, valueOffset, currentNum, nextNum);
        } while (!result);
    }

    public static void main(String[] args) throws InterruptedException {
        UnsafeLockDemo unsafeLockDemo = new UnsafeLockDemo();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                // 每个线程循环加1w次
                for (int temp = 0; temp < 10000; temp++) {
                    unsafeLockDemo.add();
                }
            }).start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println("累加后的结果:" + unsafeLockDemo.num);
    }

}

执行效果:

9.2.2 JDK提供的java.util.concurrent

针对原子类的实现i++的方式,同一时刻只有一个线程能加成功,其它的线程都失败,这样必定会造成CPU资源的损耗和浪费,JDK1.8又提供了LongAdder等专门用于计数的类。

J.U.C包内的原子操作封装类




JDK1.8后又进行了部分更新:
更新器:DoubleAccumulator、LongAccumulator
计数器:DoubleAdder、LongAdder
计数器增强版,高井发下性能更好
基本原理:频繁更新但不太频繁读取的汇总统计信息时,使用分成多个操作单元,不同线程更新不同的单元。只有需要汇总的时候才计算所有单元的操作

使用原子类实现累加

package szu.vander.atomicity;

import java.sql.Time;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author : caiwj
 * @date :   2019/11/20
 * @description : 原子递增类的使用
 */
public class AtomicAdder {

    AtomicInteger num = new AtomicInteger(0);

    public void add() {
        num.incrementAndGet();
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicAdder atomicAdder = new AtomicAdder();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10_000; j++) {
                    atomicAdder.add();
                }
            }).start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println(atomicAdder.num.get());
    }

}

9.2.2 性能比较

下面是三种加的方式进行比较:Synchronize、AtomicLong、LongAdder进行性能比较

package szu.vander.atomicity;

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

/**
 * @author : Vander
 * @date :   2019/11/20
 * @description : 测试用例: 同时运行2秒,检查谁的次数最多
 */
public class CompareAdder {
    private long syncCount = 0;

    /**
     * 同步代码块的方式
     */
    public void testSync() {
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                long startTime = System.currentTimeMillis();
                while (System.currentTimeMillis() - startTime < 2000) { // 运行两秒
                    synchronized (this) {
                        ++syncCount;
                    }
                }
                long endTime = System.currentTimeMillis();
                System.out.println("SyncThread spend:" + (endTime - startTime) + "ms" + " count:" + syncCount);
            }).start();
        }
    }

    private AtomicLong atomicLongCount = new AtomicLong(0L);

    /**
     * Atomic方式
     */
    public void testAtomic() {
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                long startTime = System.currentTimeMillis();
                while (System.currentTimeMillis() - startTime < 2000) { // 运行两秒
                    atomicLongCount.incrementAndGet();
                }
                long endTime = System.currentTimeMillis();
                System.out.println("AtomicThread spend:" + (endTime - startTime) + "ms" + " count:" + atomicLongCount.incrementAndGet());
            }).start();
        }
    }


    private LongAdder longAdderCount = new LongAdder();

    /**
     * LongAdder 方式
     */
    public void testLongAdder() {
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                long startTime = System.currentTimeMillis();
                while (System.currentTimeMillis() - startTime < 2000) { // 运行两秒
                    longAdderCount.increment();
                }
                long endTime = System.currentTimeMillis();
                System.out.println("LongAdderThread spend:" + (endTime - startTime) + "ms" + " count:" + longAdderCount.sum());
            }).start();
        }
    }

    public static void main(String[] args) {
        CompareAdder demo = new CompareAdder();
        demo.testSync();
        demo.testAtomic();
        demo.testLongAdder();
    }
}

执行结果:

可以发现JDK8新实现的累加器确实提高了接近一倍的性能,而原子类又会比同步关键字操作的累加性能提升五倍。

LongAdder实现思路:

思路就是不让多个线程操作同一个变量,作累加操作,线程1加了X次,线程2加了y次,线程3加了z次,最后通过sum方法来读取这些线程累加起来的值。
这种思路是分而治之的思路,不同的线程只Add属于它自己的变量,最后通过sum累加起来。这就类似于高并发的时候,使用集群来分担压力。

9.3 CAS机制的局限性

CAS的三个问题
1)循环+CAS,自旋的实现让所有线程都处于高频运行,争抢CPU执行时间的状态。如果操作长时间不成功,会带来很大的CPU资源消耗。
2)仅针对单个变量的操作,不能用于多个变量来实现原子操作。
3)ABA问题。(无法体现出数据的变动)
针对第一点,CAS操作适用于一些耗时较短的操作,不然长时间的不成功会导致CPU压力巨大,CAS实际上是使用自旋锁来实现的。

ABA问题

所谓的ABA问题,其实影响并不大,即线程一先修改了i的值,然后线程二又将值改回来,线程三来读取的时候就发现值没有变化,然后线程三继续进行操作。如果要避免这种情况,只需要在每次修改都增加一个修改次数的标识即可。

其它:
Unsafe类是没有注释的,要看到更详细的需要看OpenJDK。
OpenJDK官方网站:OpenJDK.java.net

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,340评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,762评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,329评论 0 329
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,678评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,583评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,995评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,493评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,145评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,293评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,250评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,267评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,973评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,556评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,648评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,873评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,257评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,809评论 2 339

推荐阅读更多精彩内容