本文首发: 我的个人博客: JoeMendez’s Blog
欢迎大家一起交流
1. 线程安全
1. 并发的风险
-
安全性问题
-
在没有充足同步的情况下, 多个线程的操作执行顺序是不可预测的, 有可能产生奇怪的结果, 比如:
public class UnsafeSequence{ private int value; public int getValue(){ return value++; // 竟态条件(Race Condition) } } // ++ 操作不是原子性操作, 在单线程时没有问题, 但是多线程时,就可能引发线程安全问题 /* i++ 不是原子操作, 是3个原子操作组合 1.读取主存中的count值, 赋值给一个局部成员变量tmp 2.tmp+1 3.将tmp赋值给count 可能会出现线程1运行到第2步的时候, tmp值为1;这时CPU调度切换到线程2执行完毕, count值为1;切换到线程1, 继续执行第3步, count被赋值为1 结果就是两个线程执行完毕, count的值只加了1, 而且两个线程获取到同样的值 */ public class Sequence{ private int value; public synchronized int getValue(){ return value++; } }
因为
i++
不是原子操作, 所以会引发多线程安全问题, 可以通过加synchronized
关键字保证同步, 这样可以保证线程安全, 但是效率会变的很慢 -
安全性的含义是"保证永远不会发生糟糕的事".
-
-
活跃性问题
- 当某个操作无法继续执行下去时, 就会发生活跃性问题. 在串行程序中活跃性问题的形式之一就是无线循环, 而在多线程中, 则是死锁, 饥饿, 活锁等.
- 活跃性意味着某件事最终会发生
2. 线程安全性
-
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时, 必须采用同步机制来协同这些线程对变量的访问.
-
完全由线程安全类构成的程序并不一定就是线程安全的, 而在线程安全类中也可以包含非线程安全的类, 只有当类中仅包含自己的状态时, 线程安全类才是有意义的
-
当多个线程访问某个类时, 这个类始终都能表现出正确的行为, 那么就称这个类是线程安全的.(无状态(不包含任何域, 也不包含任何对其他类中域的引用)对象一定是线程安全的)
public class StatelessFactorizer implements Servlet{ public void service(ServletRequest req, ServletResponse resp){ BigInteger i = extractFromRequest(req); BigIntergerp[] factors = factor[i]; encodeIntoResponse(resp, factors); } }
非原子性问题解决(CAS)
- 在 Java 并发领域, 我们解决并发安全问题最粗暴的方式就是使用 synchronized 关键字了, 但它是一种独占形式的锁, 属于悲观锁机制, 性能会大打折扣。volatile 貌似也是一个不错的选择, 但 volatile 只能保持变量的可见性, 并不保证变量的原子性操作。
- CAS 全称是
compare and swap
, 即比较并交换, 它是一种原子操作, 同时 CAS 是一种乐观机制。java.util.concurrent
包很多功能都是建立在 CAS 之上, 如ReenterLock
内部的 AQS, 各种原子类, 其底层都用 CAS来实现原子操作
原子性问题分析
-
查看 i++ 的机制
public class AddTest { public volatile int i; public void add() { i++; } }
通过
javap -c AddTest
反编译查看add方法的字节码public void add(); Code: 0: aload_0 1: dup 2: getfield #2 // Field i:I 5: iconst_1 6: iadd 7: putfield #2 // Field i:I 10: return
i++
被拆分成了几个指令:- 执行
getfield
拿到原始i
; - 执行
iadd
进行加 1 操作; - 执行
putfield
写把累加后的值写回i
。
- 执行
- 当线程 1 执行到加 1 步骤时, 由于还没有执行赋值改变变量的值, 这时候并不会刷新主内存区中的变量, 如果此时线程 2 正好要拷贝该变量的值到自己私有缓存中, 问题就出现了, 当线程 2 拷贝完以后, 线程1正好执行赋值运算, 立马更新主内存区的值, 那么此时线程 2 的副本就是旧的了, 出现了脏读.
-
如何解决呢?
-
在add方法上加上
synchronized
关键字public class AddTest { public volatile int i; public synchronized void add() { i++; } }
解决了并发安全问题, 但是性能也大打折扣
-
CAS 底层原理
CAS 的思想很简单:三个参数, 一个当前内存值 V、旧的预期值 A、即将更新的值 B, 当且仅当预期值 A 和内存值 V 相同时, 将内存值修改为 B 并返回 true, 否则什么都不做, 并返回 false。
-
查看
AtomicInteger
静态代码块片段:public class AtomicInteger extends Number implements java.io.Serializable { private static final long serialVersionUID = 6214790243416807050L; // setup to use Unsafe.compareAndSwapInt for updates private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } private volatile int value; // 省略部分代码 }
这里用到了
sun.misc.Unsafe
类, 它可以提供硬件级别的原子操作, 它可以获取某个属性在内存中的位置, 也可以修改对象的字段值, 只不过该类对一般开发而言, 很少会用到, 其底层是用C/C++
实现的, 所以它的方式都是被native
关键字修饰过的
可以看得出AtomicInteger
类存储的值是在value
字段中, 并且获取了Unsafe
实例, 在静态代码块中, 还获取了value
字段在内存中的偏移量valueOffset
-
接下来的例子使用CAS技术保证了并发安全
public class AddIntTest { public AtomicInteger i; public void add() { i.getAndIncrement(); } } // getAndIncrement(); public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // getAndAddInt() public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); // native方法 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));// native 方法, 自旋 return var5; }
-
var5 通过
this.getIntVolatile(var1, var2)
方法获取, 是个 native 方法, 其目的是获取 var1 在 var2 偏移量的值, 其中 var1 就是AtomicInteger
, var2 是 valueOffset 值 -
那么 CAS 核心重点来了,
compareAndSwapInt
就是实现 CAS 的核心方法,其原理是如果 var1 中的 value 值和 var5 相等,就证明没有其他线程改变过这个变量,那么就把 value 值更新为var5 + var4
,其中 var4 是更新的增量值;反之如果没有更新,那么 CAS 就一直采用自旋的方式继续进行操作(其实就是个 while 循环),这一步也是一个原子操作 -
举例分析:
- 设定
AtomicInteger
的 value 原始值为 A,从 Java 内存模型得知,线程 1 和线程 2 各自持有一份 value 的副本,值都是 A。 - 线程 1 通过
getIntVolatile(var1, var2)
拿到 value 值 A,这时线程 1 被挂起 - 线程 2 也通过
getIntVolatile(var1, var2)
方法获取到 value 值 A,并执行compareAndSwapInt
方法比较内存值也为 A,成功修改内存值为 B - 这时线程 1 恢复执行
compareAndSwapInt
方法比较,发现自己手里的值 A 和内存的值 B 不一致,说明该值已经被其它线程提前修改过了 - 线程 1 重新执行
getIntVolatile(var1, var2)
再次获取 value 值,因为变量 value 被 volatile 修饰,所以其它线程对它的修改,线程 A 总是能够看到,线程A继续执行compareAndSwapInt
进行比较替换,直到成功
- 设定
-
查看
hospot
的源码hotspot\src\share\vm\prims\unsafe.cpp
, 发现具体实现为:UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) UnsafeWrapper("Unsafe_CompareAndSwapInt"); oop p = JNIHandles::resolve(obj); // 根据偏移量,计算 value 的地址。这里的 offset 就是 AtomaicInteger 中的 valueOffset jint* addr = (jint *) index_oop_from_field_offset_long(p, offset); // 调用 Atomic 中的函数 cmpxchg,该函数声明于 Atomic.hpp 中 return (jint)(Atomic::cmpxchg(x, addr, e)) == e; UNSAFE_END // atomic.cpp unsigned Atomic::cmpxchg(unsigned int exchange_value, volatile unsigned int* dest, unsigned int compare_value) { assert(sizeof(unsigned int) == sizeof(jint), "more work to do"); return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest, (jint)compare_value);
- …没看懂
- 先想办法拿到变量value在内存中的地址。
- 通过
Atomic::cmpxchg
实现比较替换,其中参数x是即将更新的值,参数e是原内存的值
- …没看懂
-
CAS的问题
- ABA问题
- 有2个线程同时对同一个值(初始值为A)进行CAS操作,还有一个线程想要更新该值为A, 这3个线程如下:
- 线程1,期望值为A,欲更新的值为B
- 线程2,期望值为A,欲更新的值为B
- 线程3,期望值为B,欲更新的值为A
- 线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B, 然后线程3抢到了CPU时间片, 线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了
A->B->A
的变化过程ABA问题带来的危害:
小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。 - 通常,我们的乐观锁实现中都会带一个
version
字段来记录更改的版本, 每次变量更新的时候, 版本号+1, 避免并发操作带来的问题。在 Java 中,AtomicStampedReference
实现了这个处理方式。
- 有2个线程同时对同一个值(初始值为A)进行CAS操作,还有一个线程想要更新该值为A, 这3个线程如下:
- 循环时间长开销大
-
如果CAS操作失败,就需要循环进行CAS操作(循环同时将期望值更新为最新的),如果长时间都不成功的话,那么会造成CPU极大的开销
这种循环也称为自旋
-
解决方法: 限制自旋次数,防止进入死循环
-
- 只能保证一个共享变量的原子操作
CAS
的原子操作只能针对一个共享变量- 解决方法: 如果需要对多个共享变量进行操作,可以使用加锁方式(悲观锁)保证原子性,或者可以把多个共享变量合并成一个共享变量进行CAS操作
CAS的应用
CAS操作并不会锁住共享变量,也就是一种非阻塞的同步机制,CAS就是乐观锁的实现
-
乐观锁
乐观锁总是假设最好的情况,每次去操作数据都认为不会被别的线程修改数据,所以在每次操作数据的时候都不会给数据加锁,即在线程对数据进行操作的时候,别的线程不会阻塞仍然可以对数据进行操作,只有在需要更新数据的时候才会去判断数据是否被别的线程修改过,如果数据被修改过则会拒绝操作并且返回错误信息给用户。
-
悲观锁
悲观锁总是假设最坏的情况,每次去操作数据时候都认为会被的线程修改数据,所以在每次操作数据的时候都会给数据加锁,让别的线程无法操作这个数据,别的线程会一直阻塞直到获取到这个数据的锁。这样的话就会影响效率,比如当有个线程发生一个很耗时的操作的时候,别的线程只是想获取这个数据的值而已都要等待很久
Java利用CAS的乐观锁、原子性的特性高效解决了多线程的安全性问题,例如JDK1.8中的集合类ConcurrentHashMap
、关键字volatile
、ReentrantLock
等
参考文章:
竟态条件
-
由于不恰当的执行时序而出现不正确的结果就是竟态条件(Race Condition). 最常见的竟态条件类型就是"先检查后执行(check-then-Act)"操作, 即通过一个可能失效的观测结果来决定下一步的动作
数据竞争(data race): 如果在访问共享的非final类型的域时没有采用同步来进行协同, 那么就会出现数据竞争.
当一个线程写入一个变量, 而另一个线程接下来读取这个变量, 或者获取一个之前由另一个线程写入的变量时, 并且在这两个线程之间没有使用同步, 那么就可能出现数据竞争.
如果存在数据竞争, 那么这段代码没有确定的语义, 并非所有的竟态条件都是数据竞争, 同样并非所有的数据竞争都是竟态条件, 但二者都可能使并发程序失败
- 竟态条件并不总会产生错误, 还需要某种不恰当的执行时序
-
惰性初始化中的竟态条件(lazy initialization)
- 目的是延迟对象的初始化, 直到程序真正使用它, 同时确保他只初始化一次(单例)
@NotThreadSafe public class UnsafeLazyInitialization { private static Resource resource; public static Resource getInstance() { if (resource == null) resource = new Resource(); // unsafe publication return resource; } static class Resource { } }
-
复合操作
- 复合操作: 先检查后执行, "读取-修改-写入"等操作的全部执行过程看作复合操作, 为了保证线程安全, 操作必须原子地执行.
@ThreadSafe public class CountingFactorizer extends GenericServlet implements Servlet { // 原子类变量, 利用CAS原理实现 private final AtomicLong count = new AtomicLong(0); public long getCount() { return count.get(); } @Override public void service(ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); BigInteger[] factors = factor(i); // 原子操作 count.incrementAndGet(); encodeIntoResponse(resp, factors); } void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {} BigInteger extractFromRequest(ServletRequest req) {return null; } BigInteger[] factor(BigInteger i) { return null; } }
锁
-
尽管在程序中所有的变量都是原子操作, 但还是可能出现竟态条件, 可能产生错误的结果. 当不变性条件中涉及多个变量时, 各个变量之间并不是彼此独立的, 而是某个变量的值会对其他变量的值产生约束, 因此, 当需要更新某个变量时, 需要在同一个原子操作中对其他变量同时进行更新, 这就需要加锁来实现
@NotThreadSafe public class UnsafeCachingFactorizer extends GenericServlet implements Servlet { private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>(); private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>(); public void service(ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); if (i.equals(lastNumber.get())) // 竟态条件 encodeIntoResponse(resp, lastFactors.get()); else { BigInteger[] factors = factor(i); lastNumber.set(i);//竟态条件, 每个set是安全的, 但是合起来就不一定了, 可能只更新了一个值然后被中断 lastFactors.set(factors); encodeIntoResponse(resp, factors); } } }
- 内置锁
Java中提供了一种内置的锁机制来支持原子性:
- 同步代码块(Synchronized Block)
同步代码块包括2部分, 一个作为锁的对象引用, 一个作为由这个锁保护的代码块.
同步代码块的锁就是方法调用所在的对象, 静态的synchronized
方法以Class对象作为锁synchronized(lock) { // 由锁保护的共享状态 }
每个Java对象都可以用做一个实现同步的锁, 这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock), 线程在进入同步代码块之前会自动获得锁, 并且在退出同步代码块后自动释放锁, 无论是正常退出还是抛出异常.
Java的内置锁相当于一种互斥体, 即最多只有一个线程持有这种锁
-
重入
当某个线程请求一个由其他线程持有的锁时, 发出的线程就会阻塞. 然而由于内置锁是可重入的(某个线程试图获取一个已经由他自己持有的锁, 那么这个请求会成功).
重入意味着获取锁的操作的粒度是"线程",不是"调用".class Widget { public synchronized void doSomething() { System.ou.println("doSomething..."); } } class LoggingWidget extends Widget { public synchronized void doSomething() { System.out.println(toString() + ": calling doSomething"); super.doSomething(); } }
- 子类重写了父类的
synchronized
方法, 然后调用父类的方法, 如果没有可重入的锁, 那么这段代码将会产生死锁, 由于Widget
和LoggingWidget
中的doSomething
方法都是synchronized
方法, 因此每个doSomething
方法执行前都会获取Widget
上的锁, 如果内置锁是不可重入的, 那么调用super.doSomething
时将无法获得Widget
上的锁, 因为这个锁已经被持有.
- 子类重写了父类的
参考资料:
Java Concurrency in Practice(Java并发编程实践) [作者: Brian Goetz]