关于Java并发自增计数的探讨

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

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

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

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

关于Java并发自增计数的探讨

关于Java并发自增计数的探讨

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

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

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

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

继续测试结果:

关于Java并发自增计数的探讨

关于Java并发自增计数的探讨

关于Java并发自增计数的探讨

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

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

关于Java并发自增计数的探讨

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

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

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

关于Java并发自增计数的探讨

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

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

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

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

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

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

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

看下AtomicInteger的成员属性

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

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

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

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

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

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

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

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问题发生的几率而已;

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

0

面试题:Jvm的Hotspot的锁升级?

这边主要有三种锁的名词:偏向锁自旋锁重量级锁

之前《Java锁synchronized关键字原理的Mark Word理解》这篇文章有讲到synchronized的一些实现原理,就是在存储的Java对象的头信息里记录线程的一些信息,其中就有记录是否是偏向锁。

当线程A第一次拿到锁后,会在头信息里记录下线程A的id,然后当下次如果还是线程A拿到锁进来,那jvm就直接根据是否是偏向锁来决定是否允许它进来,也就是说这个时候其实并没有涉及到真正的锁,只是一个简单的判断。

当线程B准备竞争锁时,它会先进入到一个轻量级的锁——自旋锁,这时就相当于一个while循环(默认是10次),这时主要消耗的是cpu资源。

当自旋超过10次后,这时就会像线程C那样,开始升级申请操作系统的重量级锁,然后进入等待队列。

从中可以看出,当业务里有大量耗时长的线程时,是不适宜使用自旋锁的,因为那将非常消耗cpu资源。

0

面试题:Java线程里抛出异常会释放锁吗?

为了验证这个问题,写了一段代码来验证,它可以保证线程1先执行,如果线程1不抛出异常,那它应该一直循环输出,线程2根本进不来。

那如果抛出了异常,可以看看执行结果。

输出

+1

Java锁synchronized关键字原理的Mark Word理解

synchronized是Java里锁使用的关键字,而synchronized的底层jvm实现是依赖一个交monitor的对象。monitor管理锁的持有者的进入和退出。

当synchronized用来修饰同步代码块时,这时是由monitorenter和monitorexit指令来控制同步的。

有下面这段代码

利用jdk自带的javap命令进行解析字节码文件

其中主要关注如下信息

其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。

当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。

如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。

倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。

值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

0

Java里HashMap多线程环境下可能引起的死循环的问题的测试与解决

问题

众所周知,HashMap是线程不安全的,在并发使用HashMap时很容易出现一些问题,其中最典型的就是并发情况下扩容之后会发生死循环,导致CPU占用100%。同时,这也是一个高频面试题。

原因

引发死循环的原因,是多线程环境下,HashMap在resize扩容的时候,对新数组重新rehash的时候导致的。可以看下HashMap的源码

其中,transfer方法的核心代码分为以下四个步骤:
1.Entry next = e.next;获取要转移的下一个节点next;
2.e.next = newTable[i];使用头插法将要转移的节点插入到newTable原有的单链表中;
3.newTable[i] = e;将newTable的hash桶的指针指向要转移的节点;
4.e = next;转移下一个需要转移的节点e。

测试代码

解决

使用J.U.C下面的ConcurrentHashMap代替HashMap在并发环境下也是线程安全的。

0

如何合理的设置线程池ThreadPoolExecutor的大小

线程池究竟设置多大要看你的线程池执行的什么任务了,CPU密集型、IO密集型、混合型,任务类型不同,设置的方式也不一样

任务一般分为:CPU密集型、IO密集型、混合型,对于不同类型的任务需要分配不同大小的线程池

1、CPU密集型

尽量使用较小的线程池,一般Cpu核心数+1

因为CPU密集型任务CPU的使用率很高,若开过多的线程,只能增加线程上下文的切换次数,带来额外的开销

2、IO密集型

方法一:可以使用较大的线程池,一般CPU核心数 * 2

IO密集型CPU使用率不高,可以让CPU等待IO的时候处理别的任务,充分利用cpu时间

方法二:线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。

下面举个例子:

比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)8=32。这个公式进一步转化为:

最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1) CPU数目

3、混合型

可以将任务分为CPU密集型和IO密集型,然后分别使用不同的线程池去处理,按情况而定

0

LongAdder与 AtomicLong的异同区别

1.AtomicLong 是基于 CAS 方式自旋更新的;LongAdder 是把 value 分成若干cell,并发量低的时候,直接 CAS 更新值,成功即结束。并发量高的情况,CAS更新某个cell值和需要时对cell数据扩容,成功结束;更新失败自旋 CAS 更新 cell值。取值的时候,调用 sum() 方法进行每个cell累加。
2.AtomicLong 包含有原子性的读、写结合的api;LongAdder 没有原子性的读、写结合的api,能保证结果最终一致性。
3.低并发场景AtomicLong 和 LongAdder 性能相似,高并发场景 LongAdder 性能优于 AtomicLong。

0