java 对象引用赋值是否原子操作_9. 线程安全之原子操作

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

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

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

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

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

你可能感兴趣的:(java,对象引用赋值是否原子操作)