1、初步引入Sync和CAS
在同一package下,有如下代码:
public class A {
int num = 0;
public long getNum(){
return num;
}
public void increase(){
num++;
}
}
public class LockTest {
public static void main(String[] args) throws InterruptedException {
A a = new A();
long start = System.currentTimeMillis();
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1000000000; i++) {
a.increase();
}
});
t1.start();
for (long i = 0; i < 1000000000; i++) {
a.increase();
}
t1.join();
long end = System.currentTimeMillis();
System.out.println(String.format("%sms",end - start));
System.out.printf("%sms",end - start);
System.out.println();
System.out.println(a.getNum());
}
}
分析代码,我们知道因为没有对线程进行一定的并发控制,它的结果会不是预期的值(2000000000)。以下是两次运行的结果,因为每次并发时的情况不同结果一般不同:
当我们修改代码时(在increase方法上加上synchronized锁时),注意这里加的锁它锁的是类的当前的对象,也就是类A的当前对象(this)
synchronized修饰不加static的方法,锁是加在单个对象上,不同的对象没有竞争关系;
Synchronized修饰非静态方法,实际上是对调用该方法的对象加锁,俗称“对象锁”。
synchronized修饰加了static的方法,锁是加载类上,这个类所有的对象竞争一把锁。
Synchronized修饰静态方法,实际上是对该类对象加锁,俗称“类锁”。
public synchronized void increase(){
num++;
}
我们能够达到正确的结果:
显而易见的是,这种方式处理并发是比较吃力的。
上图是对synchronized的一个解释图。synchronized锁对this对象进行加锁,实际上是对this对象中的monitor对象进行加锁。线程1对方法进行调度,线程2、3等待线程1锁释放(等待队列)
去了解java中的monitor机制,讲得确实挺不错的https://zhuanlan.zhihu.com/p/356010805
(有空细究)
下面介绍该问题另外一种控制并发的方法,下面是更改后的代码:
public class A {
AtomicInteger num = new AtomicInteger();
public long getNum(){
return num.get();
}
public void increase(){
num.incrementAndGet();
}
}
public class LockTest {
public static void main(String[] args) throws InterruptedException {
A a = new A();
long start = System.currentTimeMillis();
Thread t1 = new Thread(() -> {
for (long i = 0; i < 1000000000; i++) {
a.increase();
}
});
t1.start();
for (long i = 0; i < 1000000000; i++) {
a.increase();
}
t1.join();
long end = System.currentTimeMillis();
System.out.println(String.format("%sms",end - start));
System.out.printf("%sms",end - start);
System.out.println();
System.out.println(a.getNum());
}
}
运行结果如下,很明显要快很多,从102402ms到15886ms
2、CAS
1、AtomicInteger底层实现原理是什么?
AutomicInteger是对int类型的一个封装,提供原子性的访问和更新操作,原子操作的实现是基于cas(compare-and-swap)来实现的。
2、什么是原子性访问?
一组操作要么全部成功,要么全部失败。
以下是对AutomicInteger这一并发类核心代码的实现:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerTest {
// 全局唯一id
static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 每个线程的唯一id都自增
//int i = atomicInteger.incrementAndGet();//调用官方的方法
int i = incrementAndGet(atomicInteger);//调用自己实现的的方法
System.out.println(i);
}
});
thread.start();
}
}
/*
* 自己实现incrementAndGet方法,即当前值加1【线程安全】
* */
public static int incrementAndGet(AtomicInteger var) {
int expect, next;
do {
expect = var.get();
next = expect + 1;
} while (!var.compareAndSet(expect, next));
/*
* 对于上面的while:
* 1、如果当前的值等于期待的值,那么返回true
* 2、如果不等于,那么的返回false
* */
return next;
}
}
解释一下incrementAndGet方法的实现逻辑:
一开始定义int类型的两个变量:expect(预期值),next(下一个值)。在这个do...while循环中,循环体中先将当前内存中的var(也就是传入的AtomicInteger类型的automicInteger)值获取到给expect,再+1赋值给next。在循环条件中,调用的是AtomicInteger类的核心方法compareAndSet,这个方法顾名思义:先比较再赋值。因为有不同的多个线程存在,而每个线程都会先后要对var的值进行修改。如果一个线程A调用incrementAndGet时,预期值expect如果与当前var的值相等,才会将next赋值给var,假设线程A满足上面的要求,而同时如果在此时刻另外一个线程B也刚好调用这一方法时,由于var的值已经被线程A改变,这些线程的expect值就不会与比较时的var相等,所以它只能重新进入到循环中,期待下一次的相遇,但即使是走到最后也都是会找到自己的唯一。
运行结果:
这里如果还要深挖compareAndSet的最底层,我们可以看到final native
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
它的底层是用c++实现的,这方面的源码可以自行查找资料。关乎到的就是操作系统方面的知识(汇编语言)。
可以这样说:compareAndSet内部是实现了原子性的,通过lock cmpxchgq实现了。(在java代码层面它是无锁的,在硬件层面还是有锁的,锁的颗粒度不同)
可能有人会有疑惑:为什么compareAndSet已经加了lock锁,为什么还会被其它线程抢走?
因为这里时乐观锁,它先获取预期值和设定下一个值,这一步是不加锁的,只有在比较交换时加锁的。(执行到最底层的那一步)
CAS无锁机制与ABA问题
1、原子性问题
lock cmpxchgq 缓存行锁/总线锁(这个就是一条原子命令,不会被其他线程打断执行或插入执行)
2、ABA问题
2.1、ABA问题的产生
要了解什么是ABA问题,首先我们来通俗的看一下这个例子,一家火锅店为了生意推出了一个特别活动,凡是在五一期间的老用户凡是卡里余额小于20的,赠送10元,但是这种活动没人只可享受一次。然后火锅店的后台程序员小王开始工作了,很简单就用cas技术,先去用户卡里的余额,然后包装成AtomicInteger,写一个判断,开启10个线程,然后判断小于20的,一律加20,然后就很开心的交差了。可是过了一段时间,发现账面亏损的厉害,老板起先的预支是2000块,因为店里的会员总共也就100多个,就算每人都符合条件,最多也就2000啊,怎么预支了这么多。小王一下就懵逼了,赶紧debug,tail -f一下日志,这不看不知道,一看吓一跳,有个客户被充值了10次!
阐述:
假设有个线程A去判断账户里的钱此时是15,满足条件,直接+20,这时候卡里余额是35.但是此时不巧,正好在连锁店里,这个客人正在消费,又消费了20,此时卡里余额又为15,线程B去执行扫描账户的时候,发现它又小于20,又用过cas给它加了20,这样的话就相当于加了两次,这样循环往复肯定把老板的钱就坑没了!
本质:
ABA问题的根本在于cas在修改变量的时候,无法记录变量的状态,比如修改的次数,否修改过这个变量。这样就很容易在一个线程将A修改成B时,另一个线程又会把B修改成A,造成casd多次执行的问题。
2.2、AtomicStampReference
AtomicStampReference在cas的基础上增加了一个标记stamp,使用这个标记可以用来觉察数据是否发生变化,给数据带上了一种实效性的检验。它有以下几个参数:
//参数代表的含义分别是 期望值,写入的新值,期望标记,新标记值
public boolean compareAndSet(V expected,V newReference,int expectedStamp,int newStamp);
public V getRerference();
public int getStamp();
public void set(V newReference,int newStamp);
2.3、AtomicStampReference的使用实例
我们定义了一个money值为19,然后使用了stamp这个标记,这样每次当cas执行成功的时候都会给原来的标记值+1。而后来的线程来执行的时候就因为stamp不符合条件而使cas无法成功,这就保证了每次只会被执行一次。
public class AtomicStampReferenceDemo {
static AtomicStampedReference<Integer> money =new AtomicStampedReference<Integer>(19,0);
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
int stamp = money.getStamp();
System.out.println("stamp的值是"+stamp);
new Thread(){ //充值线程
@Override
public void run() {
while (true){
Integer account = money.getReference();
if (account<20){
if (money.compareAndSet(account,account+20,stamp,stamp+1)){
System.out.println("余额小于20元,充值成功,目前余额:"+money.getReference()+"元");
break;
}
}else {
System.out.println("余额大于20元,无需充值");
}
}
}
}.start();
}
new Thread(){
@Override
public void run() { //消费线程
for (int j = 0; j < 100; j++) {
while (true){
int timeStamp = money.getStamp();//1
int currentMoney =money.getReference();//39
if (currentMoney>10){
System.out.println("当前账户余额大于10元");
if (money.compareAndSet(currentMoney,currentMoney-10,timeStamp,timeStamp+1)){
System.out.println("消费者成功消费10元,余额"+money.getReference());
break;
}
}else {
System.out.println("没有足够的金额");
break;
}
try {
Thread.sleep(1000);
}catch (Exception ex){
ex.printStackTrace();
break;
}
}
}
}
}.start();
}
}
这样实现了线程去充值和消费,通过stamp这个标记属性来记录cas每次设置值的操作,而下一次再cas操作时,由于期望的stamp与现有的stamp不一样,因此就会设值失败,从而杜绝了ABA问题的复现。