因为最近自己在培训机构重造,课程进度略慢略浅,所以就想在平时上课的内容中做一些横向拓展,希望能够记忆深刻一些,所以就将一些个人觉得比较重要的内容通过博客的形式记录下来,主要还是起一个学习笔记的作用吧,所以有些地方可能比较混乱,还望路过的老哥见谅,如果发现有错的地方希望帮忙给小弟指正指正,感激不尽。
言归正传,今天看博客的时候看到AtomicInteger,回顾了一下,自己脑子了除了知道他是一个原子操作相关的类就没有什么印象了,所以就准备再来啃啃这块略硬的骨头。
一、AtomicInteger存在的意义
光从类名来看的话感觉AtomicInteger就是对Integer类的增强,AtomicInteger相关的API是在jdk1.5版本后添加的,我们也知道了这个类是与原子操作相关的,那么我们从头出发,先搞清楚什么是原子操作。
1.1 原子操作
如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构,那么这个操作是一个原子(atomic)操作。
原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分。
将整个操作视作一个整体是原子性的核心特征。
这是百度百科对于原子操作的定义,通俗来说原子操作就算一系列不可拆分的操作,要么全部执行要么全部不执行。这个概念其实很多地方都有用到,如数据库的的四大特性之中的原子性(为了防止忘了,顺便提一嘴数据库的四大特性:原子性、一致性、隔离性、持久性),东西越学越多过后会发现很多思想性的东西都是相通的,所以要多总结多思考,后面学习新东西时肯定也会容易些。
知道了原子操作是什么,那么原子操作为什么要被添加到JAVA相关的API里面来呢?又为什么在1.5这个java已经发展了一段时间后的版本才来添加呢?一起再来看看吧。
1.2 线程安全
只要有多线程的地方就会出现线程安全问题,java必不例外。下面来看个例子,是我今天逛的大部分博客中都出现了例子,确实也是比较能说明问题。
package com.ddup.atomic.integer; public class TestDemo { private static int count = 0; public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 100; i++) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); } for (int j = 0; j < 1000; j++) { count++; } } }).start(); } while (Thread.activeCount() > 2) { Thread.sleep(1); } System.out.println(count); } }
上面的代码也很简单,开启100个线程对同一个变量count分别进行1000次自增操作,我们对程序的期望值是输出 100000,实际情况是无论运行多少次结果都是小于100000的。
原因很简单,每个线程中的 ++ 操作实际上可以分为三步: 获取当前变量值、给当前值+1、将新变量值写回处。正是因为这三步的存在,所以才导致了意外的结果。因为线程不可能保持速度完全的一致,所以每个线程进行到的具体步骤不同,就会导致结果低于2000;
要解决问题很简单,我们都学过synchronized修饰符,知道用它修饰的代码块(或者方法)可以维持线程安全,事实也确实是如此的。使用但synchronized一直以来都有个令人诟病的缺点,就是synchronize实现是使用的阻塞式的线程同步方案,也就是说只能同时运行一个线程,其他线程在当前线程释放锁之前都是等待状态,对于性能的损耗是非常大的。于是AutomicInteger相关类的优势就展现出来了。
二、AtomicInteger解析
前面已经介绍过原子操作了,顾名思义AtomicInteger就是原子操作的Integer,将上面例子中的代码中的共享变量替换为AutomicInteger,将count++替换为 count.getAndIncrement() 或者 count.incrementAndGet(),那就能达到我们的预期结果了。这两个方法本质上是没什么区别的,只是一个方法返回的是更新后的值,一个返回的是更新前的值。
要想知道它是如何通过不阻塞的方式来实现线程安全的的,那就必须深入到源码来找原因了。
多看源码身体好!
那先从简单的入手,先看看AtomicInteger的成员变量、构造方法以及初始化用的静态代码块。
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; public AtomicInteger(int initialValue) { value = initialValue; } public AtomicInteger() { }
value、构造方法我们肯定是能理解的(value使用的volatile修饰符也很意思,我打算下次重新写一篇来单独介绍),static是在对valueOffset进行初始化也能够看出来,但是这个valueOffset和这个Unsafe是什么呢?生啃一会儿没有进展的情况下,通过翻阅博客了解到了valueOffset名为内存偏移量,名字看起来很不好理解对吧,其实就是一个指向value变量在内存中的地址值,用处会在后面讲到,现在关键我们需要搞明白这个Unsafe是什么,才能理解为什么AtomicInteger能够实现线程同步。Unsafe这个类名字非常有意思,从名字上就直接告诉了我们它非常不安全,点进这个类的源码我们很快就知道了原因,里面的大部分类都使用native修饰,我们都知道一般和操作系统或者硬件相关的操作才会使用native方法,这个类中大篇幅的native方法,当然非常不安全了,所以这个类只能被jdk内部代码调用,我们是碰不到的。大概了解了Offset后,我们也就i知道了静态代码块中的代码就是通过unsafe中的native方法获取到value的内存偏移量并赋给了valueOffset。
接下来再看我们用到的getAndIncrement()方法,
public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); }
再继续到unsafe里看看有没有什么猫腻
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2);① } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4) ②); return var5; }
真相渐渐的浮出水面了,我们直接看Unsafe中的getAndAddInt方法,它接收了当前对象的引用、内存偏移量、和一个增长量。①处的代码是一个获取volatileInt,关于volatile先不过多赘述,我们只要知道是要获取value内存地址处的最新值就行。最关键的代码就是②这一句了,这个方法的参数从左到右分别是当前对象引用、value内存偏移量、从内存中取到的value值、以及当前需要更新的值,有了这些值,就已经具备了达到AtomicInteger关键机制CAS的条件了。
三、乐观锁之CAS
我们都知道为实现线程安全最常见的解决方案就是sychronized,synchronized是典型的悲观锁,默认认为每一次操作都会发生冲突,所以在同一时间段仅允许一个线程进行操作(阻塞),这样最明显直观的感受就是效率低下(在目前的版本中,为提升效率synchronized在转换为重量锁之前也是使用的CAS机制来实现了)。有悲观锁必然就有乐观锁,乐观锁采用更宽松的加锁机制,它认为每次线程操作都不会发生冲突,接下来讲讲乐观锁的实现。乐观锁的关键如下,
预期值:修改操作前对当前数据的预期值,如果实际值与预期值不符,则放弃当前操作,获取最新的预期值并再次尝试,直到实际值与预期值相符。
目标值:对当前数据修改的目标值,若预期值与实际值相同时,就修改实际值。
看到这里我们也就明白了AtomicInteger的实现原理了,将将var5与内存中var2位置的值进行比较,如果相同则将var5 + var4修改到var2位置,如果不相同则获取最新的var2处值赋值给var5再次尝试。成功替换后返回true退出循环,返回修改前var2处的值。
四、CAS的弊端
1、ABA问题
之前在一篇博客中看到过一个关于ABA问题非常形象的例子,看完你一定能理解ABA问题。
小明的银行卡中有100元,有一天他要取五十块钱出来,但是取款机出了问题,取钱的操作被提交了两次,理想的情况下是一次操作成功另一次操作失败。取钱的两次操作分别为线程1和线程2,线程1和线程2的预期值都是100,目标值都是50。线程1先顺利操作成功,将实际值修改为50,但线程2因为其他未知原因阻塞了,暂时未进行操作。这时小明妈妈正好给小明汇款,开启汇款线程3,预期值50目标值100,顺利更新成功,实际值又被更新为100。突然小明取钱的线程2又恢复了正常,看到当前银行卡中的实际值正好符合预期值,便执行更新操作,将实际值更新为了50。小明哭了。
不知道我描述的够不够清楚,如果觉得没太懂的同学可以移步到大佬博客,大佬讲的要生动清楚些,我写出来也主要是一个加强记忆的作用。---->什么是CAS机制?
大佬也明确的给了解决方案,就是通过添加版本号,每一次修改都版本号都会+1,这样就有效的避免了ABA的问题。
2、性能开销大
这个也很好理解,一个线程如果每次预期值与实际值都不匹配,那它一直重复同样的操作,不断的获取和比较,如果同时有多个线程进行循环操作,对于CPU的开销还是非常大的。