1. 竞态条件
在并发编程中,多线程会抢占运行各自的代码片段,当访问共享数据时会产生不正确的结果。
public class ConditionRaceExp {
private static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[1000];
for(int i=0;i<1000;i++){
threads[i] = new Thread(()->{
for(int k=0;k<10000;k++)
counter++;
});
}
for(int i=0;i<1000;i++){
threads[i].start();
}
for(int i=0;i<1000;i++){
threads[i].join();
}
System.out.println(counter);
}
}
预期结果应该是1000000,但实际运行结果与预期不符。
原因是因为在执行counter++时,等同于counter = counter+1,在多线程环境中这句命令会被打断,比如A线程在执行该语句时,counter瞬时的值为100,A线程执行counter+1,得到值为101,在赋值回counter时,问题发生了——B、C、D线程抢占了CPU资源,并将counter值加到了103,等A线程回到CPU运行的时候,又会把101赋值给counter。 此种情况被成为竞态条件。
2. 原子性
要让程序能得到预期的结果,需要让涉及到竞态条件的程序指令的原子性不被破坏。此时需要将程序进行保护。JAVA提供了锁机制来保护程序,根策略不同可以分为悲观锁和乐观锁两种。
2.1 悲观锁
首先需要定义一个对象,锁是上在某个普通对象上的,也可以是某个class对象上。上锁的过程其实就是在对象头上更改状态位、以及写入自己的信息。如果没有指明,则根据方法来判定,如果是实例方法,则锁在this对象上。如果是静态方法,则锁在这个实例所在的类对象上。
Java调用锁有多种方法,一般使用synchronized方法对象。
tips:在发生异常后,线程会自己释放锁的。
Object o = new Object();
synchronized(o){
//此时,这把锁上在 o 这个对象上
}
synchronized(T.class){
//此时,是上锁在T.class 这个对象,这个对象是Class类型的,是类加载的虚拟机后创建的。
}
2.2 乐观锁
乐观锁的实现是CAS(Compare and Swap)算法,CAS算法并没有真正“锁定”内存的对象,也被称为无锁算法。
CAS底层原理是,CAS在为对象赋值的时候,会带上对象的预期的值,将预期值和当前值进行比较(Compare),如果是一致的,就进行赋值(Swap)。否则继续重试,因此也称为自旋锁。CAS存在ABA的问题,一般是通过版本号或时间戳加上数据进行双重检验。
CAS在底层是由操作系统保证的,操作系统会调用指令 lock cmpxchg 对缓存行或总线加锁,保证原子性。
2.3 两种锁的比较
尽管悲观锁需要对锁资源进行抢占和排队,但是排队时线程并不占据CPU资源;而乐观锁的线程都会持续自旋(循环轮询)占用CPU的时间片段,因此根据线程并发数和实际测试情况,才能判断那种锁的效率高。
3.加锁的方法
- 使用synchronized关键字
- 使用ReentrantLock对象
- 使用JUC库的各种工具类(详见进阶篇)
总结
锁能保证原子性。但是并不代表上锁就一定安全,程序的设计需要考虑周到。锁分为乐观锁和悲观锁,悲观锁基于对象头锁定,乐观锁基于CAS算法。对于并发数少、执行时间比较短的情况,建议使用CAS;否则建议使用悲观锁。
多线程系列在github上有一个开源项目,主要是本系列博客的实验代码。
https://github.com/forestnlp/concurrentlab
如果您对软件开发、机器学习、深度学习有兴趣请关注本博客,将持续推出Java、软件架构、深度学习相关专栏。
您的支持是对我最大的鼓励。