关于并发自增计数引发的探索与发现

前言
关于并发,一直都有所了解,但并没有真正认真深入的学习过。今天开始探索一下Java并发篇,玩一下多线程。了解了一下关于计数器的多线程下的四种情况。

第一种情况
编写一个类Counter里面有一个成员变量count,写一段简单的i++的代码完成计数的功能,为了暴露多线程下的问题,让每次自增之前睡100ms

public class Counter {

    private int count;

    private static Map systemTimeMillis = new HashMap();

    public void add(){
        systemTimeMillis.put(Thread.currentThread().getId(),System.currentTimeMillis());
        try {
            for(int i=0;i<200;i++){
                Thread.sleep(100);
                    this.count++;
                    System.out.println("ThreadId:"+Thread.currentThread().getId()+":"+count);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("ThreadId:"+Thread.currentThread().getId()+",耗时:"+(System.currentTimeMillis()-(Long)systemTimeMillis.get(Thread.currentThread().getId())));
    }

    public Map getSystemTimeMillis() {
        return systemTimeMillis;
    }

    public void setSystemTimeMillis(Map systemTimeMillis) {
        this.systemTimeMillis = systemTimeMillis;
    }
}

开三个线程测试下会出现什么情况

public static void main(String[] args) {
    final Counter counter = new Counter();
    new Thread(new Runnable() {
        @Override
        public void run() {
            counter.add();
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            counter.add();
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            counter.add();
        }
    }).start();
}

根据代码逻辑,我们很清楚的知道count是一个共享变量,最终结果如果是理想情况不出错的情况下,开三个线程,count最终结果应该是600。然而理想情况一般是不存在的,三个线程避免不了打架,运行情况看下面两张图

1555770307091.png

1555770375786.png

根据这两张图很明显,中间出现了错误的情况,首先是515线程号12,14输出了两次,而且最终结果是547

这就影响了程序运行的正确性,这就是线程不安全的实例,并发情况出现了。当然了,这种情况解决很简单,应该每个了解过Java多线程的程序员都会知道,一个synchronized关键字就可以解决这样的问题。

synchronized小概念,内部锁,可重入锁。根据JVM计数器实现。
同一个类中相同或不同同步方法,在被调用的时候,调用者是同一个对象,可以重复进入方法体。

第二种情况,我将add()方法代码改为如下:

public synchronized void add(){
    systemTimeMillis.put(Thread.currentThread().getId(),System.currentTimeMillis());
    try {
        for(int i=0;i<200;i++){
            Thread.sleep(100);
                this.count++;
                System.out.println("ThreadId:"+Thread.currentThread().getId()+":"+count);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("ThreadId:"+Thread.currentThread().getId()+",耗时:"+(System.currentTimeMillis()-(Long)systemTimeMillis.get(Thread.currentThread().getId())));
}

继续测试结果:

1555771173199.png

1555771186622.png

1555771205687.png

如图可见,三个线程规规矩矩的依次执行完,那么这样避免了线程不安全的出现,但是就诞生了另外一个问题,大家想一下,这个是不是跟串行差不了太多了,三个线程依次执行,最终结果虽然没有出错,但是效率也近乎增加了三倍,总耗时应该是三次累计,比起刚才慢了三倍。其实没必要同步整个方法,只需要同步count++就可以了,count在多线程术语中被称为竞态条件。

第三种情况,我再修改下add()方法,这次锁count++,当然输出也要锁到,不然虽然count最终不会出错,但输出有可能会出错:

public void add(){
    systemTimeMillis.put(Thread.currentThread().getId(),System.currentTimeMillis());
    try {
        for(int i=0;i<200;i++){
            Thread.sleep(100);
            synchronized (this){
                this.count++;
                System.out.println("ThreadId:"+Thread.currentThread().getId()+":"+count);
            }
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("ThreadId:"+Thread.currentThread().getId()+",耗时:"+(System.currentTimeMillis()-(Long)systemTimeMillis.get(Thread.currentThread().getId())));
}

1555771860647.png

看到输出结果,count又被三个线程交错增加,输出结果时间几乎同步,效率不会像刚才那种锁方法的低,而且保证了线程安全,用synchronized还是不要无脑锁方法,会影响效率的,但是如果锁实例的时候就要判断好竞态条件,保证加了锁之后最终是一致的,保证线程安全。

此外还有一种方式,第四种情况,利用AtomicInteger原子类对象自增:

private AtomicInteger count = new AtomicInteger(0);





 public void add(){
        systemTimeMillis.put(Thread.currentThread().getId(),System.currentTimeMillis());
        try {
            for(int i=0;i<200;i++){
                Thread.sleep(100);
                synchronized (this){
                    System.out.println("ThreadId:"+Thread.currentThread().getId()+":"+count.incrementAndGet());
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("ThreadId:"+Thread.currentThread().getId()+",耗时:"+(System.currentTimeMillis()-(Long)systemTimeMillis.get(Thread.currentThread().getId())));
    }

测试结果仍然保证了线程安全。

1555772499598.png

AtomicInteger是Java并发包下面的一个Integer原子操作类。通过命名也可以看得出来,它可以保证原子操作,那么,什么是原子操作?

如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构,那么这个操作是一个原子(atomic)操作。

原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分。将整个操作视作一个整体是原子性的核心特征。

在这里我的理解,在这个程序里就是,稳定+1,没有其他操作可以妨碍我+1,在程序执行过程中,我这个+1不管是一个步骤还是多个步骤,该线程总要给我+1之后才会执行其他的部分。

还有一种老生常谈的说法是,原子操作(atomic operation)是不需要synchronized的。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。原子操作是不可分割的,在执行完毕之前不会被任何其它任务或事件中断。

通过这些解释,可以说明AtomicInteger是可以保证线程安全的。那么再探索一下AtomicInteger的自增方法实现原理吧…

首先点进AtomicInteger的有参构造,注释的意思是:创建一个AtomicInteger(原子整数),给一个初始的value

/**
 * Creates a new AtomicInteger with the given initial value.
 *
 * @param initialValue the initial value
 */
public AtomicInteger(int initialValue) {
    value = initialValue;
}

看下AtomicInteger的成员属性

有一个Unsafe类型的成员变量,上面有一句注释,建立去用Unsafe对象去比较和转换Int为了更新,翻译的有点蹩脚,意思就是利用Unsafe对象去比较转换Int值的变化。其实就是负责与CAS(compare and swap)相关的操作实现。

valueOffset,从命名上来看是一个偏移量,实际上是用来记录内存首地址的偏移量。

value,这个是用来存放int值的。

静态块,是用来给偏移量初始化,在该原子类加载的时候利用字节码对象反射去获取该value在内存上存储的首地址偏移量,意思可以理解为就是该整型变量在内存上存储的位置的一个记录(标识)。

valatile关键字,在这里保证其对其他线程可见。

// 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;

我调用那个自增方法是incrementAndGet,注释:每个当前value原子的增加1

/**
 * Atomically increments by one the current value.
 *
 * @return the updated value
 */
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

点到Unsafe类的方法 getAndAddInt(this, valueOffset, 1) 实现

public final int getAndAddInt(Object paramObject, long paramLong, int paramInt) {
    int i;
    do {
        i = this.getIntVolatile(paramObject, paramLong);
    } while(!this.compareAndSwapInt(paramObject, paramLong, i, i + paramInt));

    return i;
}

this.getIntVolatile(paramObject, paramLong);方法是利用内存偏移量获取到当前value的值,然后一直利用CAS比较转换,直到CAS比较转换成功。incrementAndGet对应++i,是返回自增之后的值。同样AtomicInteger类中也包含i++,i–,--i之类对应的方法。

说到CAS就会谈到ABA。是CAS引发了ABA问题,CAS也并非完美。就是说,当前内存的值一开始是A,被另外一个线程先改为B然后再改为A,那么当前线程访问的时候发现是A,则认为它没有被其他线程访问过。在某些场景下这样是存在错误风险的。比如在链表中。

ABA问题如何解决?

两种优化思路:

AtomicStampedReference 本质是有一个int 值作为版本号,每次更改前先取到这个int值的版本号,等到修改的时候,比较当前版本号与当前线程持有的版本号是否一致,如果一致,则进行修改,并将版本号+1(当然加多少或减多少都是可以自己定义的),在zookeeper中保持数据的一致性也是用的这种方式;

AtomicMarkableReference则是将一个boolean值作是否有更改的标记,本质就是它的版本号只有两个,true和false,修改的时候在这两个版本号之间来回切换,这样做并不能解决ABA的问题,只是会降低ABA问题发生的几率而已;

具体实现见对应源码,本篇就不再解读这两个类的源码了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值