前言:上一节学习了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