锁的优化及注意事项

Java高并发程序设计第4章

目录

一、提高锁性能的几点建议

1.1 减小锁持有时间

1.2 减小锁粒度

1.3 读写分离锁来替换独占锁

1.4 锁分离

1.5 锁粗化

二、Java虚拟机对锁优化所做的努力

2.1 偏向锁

2.2 轻量级锁

2.3 自旋锁

2.4 锁清除

三、人手一支笔:ThreadLocal

四、无锁

1.与众不同的并发策略:比较交换(CAS)

2.无锁的线程安全整数:AtomicInteger

3.Java中的指针:Unsafe类

4.无锁的对象引用:AtomicReference

5.带有时间戳的对象引用:AtomicStampedReference

6.数组也能无锁:AtomicIntegerArray

7.让普通变量也享受原子操作:AtomicIntegerFieldUpdater

8.挑战无锁算法:无锁的Vector实现

9.SynchronousQueue的实现


一、提高锁性能的几点建议

锁的竞争会导致程序整体性能的下降,如何降低锁竞争带来的副作用是我们必须考虑的。下面提出几点锁优化的建议:

1.1 减小锁持有时间

单个线程对锁的持有时间与系统的性能密切相关。如果线程持有锁的时间越长,那么锁的竞争程度就会越激烈。因此,应尽可能减少线程对某个锁的占有时间,进而减少线程间互斥的可能。看下面这段代码:

public synchronized void syncMethod() {
    othercode1();
    mutexMethod();
    othercode2();
}

假设只有mutexMethod()有同步需要,而othercode1()和othercode2()不需要做同步控制。如果othercode1()和othercode2()都是重量级的方法,那么就会花费较长的CPU时间。改进后的代码如下:

public void syncMethod() {
    othercode1();
    synchronized(this) {
        mutexMethod();
    }
    othercode2();
}

只对需要同步的方法进行同步控制,这样锁的占用时间会大大减少,进而提高系统的并行性能。

1.2 减小锁粒度

对于HashMap来说,最重要的两个方法就是get()和put()。一种最自然的的想法就是对整个HashMap加锁,必然可以得到一个线程安全的对象。但是这样做,我们就认为加锁粒度太大。对于ConcurrentHashMap(JDK1.7实现),它内部进一步细分了若干个小的HashMap,称之为段(SEGMENT)。默认情况下,一个ConcurrentHashMap被进一步细分为16个段。

如果需要在ConcurrentHashMap中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode得到该表项应该被存放到哪个段中,然后对该段加锁,并完成put()操作。在多线程环境中,如果多个线程同时进行put()操作,只要被加入的表项不存放在同一个段中,则线程间便可以做到真正的并行。下面代码展示了put()操作的过程:

public V put(K key, V value) {
    Segment s;
    if (value == null)
        throw new NullPointerException();
    //获取段的序号
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment)UNSAFE.getObject // nonvolatile; recheck
      (segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
        //得到段
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

 

但是这样会存在一个问题:当系统需要取得全局锁时,其消耗的资源会比较多。仍然以ConcurrentHashMap类为例,虽然其put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap全局信息时,就会需要同时取得所有段的锁方能顺利实施。比如ConcurrentHashMap的size()方法,它将返回ConcurrentHashMap的有效表项的数量,即ConcurrentHashMap的全部有效表项之和。要获得这个信息需要取得所有子段的锁。下面是size()方法的部分代码:

sum = 0;
for(int i=0; i     segments[i].lock();
for(int i=0; i     sum += segments[i].count;
for(int i=0; i     segments[i].unlock();

 

可以看到在计算总数时,先要获得所有段的锁,然后再求和。但是,ConcurrentHashMap的size()方法并不总是这样执行,事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方法。

1.3 读写分离锁来替换独占锁

在读多写少的场合,读写锁对系统性能是很有好处的。因为如果系统在读写数据时均使用独占锁,那么读操作和写操作间、读操作和读操作间、写操作和写操作间均不能做到真正的并发,并且需要相互等待。而读操作本身不会影响数据的完整性和一致性。因此,理论上,在大部分情况下,应该可以允许多线程同时读,读写锁正是实现了这种功能。

1.4 锁分离

锁分离是读写锁的而进一步延伸。 一个典型的案例就是java.util.concurrent.LinkedBlockingQueue的实现。
在LinkedBlockingQueue的实现中,take()函数和put()函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此,两个操作分别作用于队列的前端和尾端,从理论上说,并不冲突。

如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()和put()操作就不可能真正的并发,在运行时,它们会彼此等待对方释放锁资源。在这种情况下,锁竞争会相对比较激烈,从而影响程序在高并发时的性能。因此,在JDK的实现中,并没有采用这样的方式,取而代之的是两把不同的锁,分离了take()和put()操作。

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

take()与put()函数相互独立,不存在锁的竞争关系。只需要在take()和take()间、put()和put()间分别对takeLock和putLock进行竞争。从而,削弱了锁竞争的可能性。
函数take()的实现如下:

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly(); //不能有两个线程同时取数据
    try {
        while (count.get() == 0) { //如果当前没有可用数据,一直等待
            notEmpty.await(); //等待,put()操作的通知
        }
        x = dequeue(); //取得第一个数据
        c = count.getAndDecrement(); //数量减1,原子操作
        if (c > 1)
            notEmpty.signal(); //通知其他take()操作
        } finally {
            takeLock.unlock(); //释放锁
        }
    if (c == capacity)
        signalNotFull(); //通知put()操作,已有空余空间
    return x;
}

函数put()的实现如下:

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    int c = -1;
    Node node = new Node(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly(); //不能有两个线程同时进行put()
    try {
        while (count.get() == capacity) { //如果队列已经满了
            notFull.await(); //等待
        }
        enqueue(node); //插入数据
        c = count.getAndIncrement(); //更新总数,变量c是count加1前的值
        if (c + 1 < capacity)
            notFull.signal(); //有足够的空间,通知其他线程
    } finally {
        putLock.unlock(); //释放锁
    }
    if (c == 0)
    signalNotEmpty(); //插入成功后,通知take()操作取数据,此时会调用notEmpty.signalAll()
}

1.5 锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,如果对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

为此,虚拟机在遇到一连串连读地对同一锁不断进行请求和释放的操作时,便会把所有的锁作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作叫做锁是粗化。比如代码段:

public void demoMethod() {
    synchronized(lock) {
        //do sth
    }
    //做其他不需要的同步的工作,但能很快执行完毕
    synchronized(lock) {
        //do sth
    }
}

 

按照锁粗化的思想,整合后代码如下:

public void demoMethod() {
    synchronized(lock) {
        //do sth
        //做其他不需要的同步的工作,但能很快执行完毕
    }
}

 

二、Java虚拟机对锁优化所做的努力

2.1 偏向锁

偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入了偏向模式。当这个线程再次请求锁的时候无需再去做任何同步操作,节省了锁的申请操作,提高程序的性能。偏向锁不适合锁竞争激烈的情况。使用Java虚拟机参数-XX:UseBiasedLocking可以开启偏向锁。

2.2 轻量级锁

如果偏向锁失败,虚拟机并不会立即挂起线程。它还会使用一种称为轻量级锁的优化手段。轻量级锁的操作也很轻便,它只是简单地将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。

2.3 自旋锁

锁膨胀后,虚拟机为了避免线程真实地在操作系统层面挂起,虚拟机还会在做最后的努力–自旋锁。由于当前线程暂时无法获得锁,但是什么时候可以获得锁是一个未知数。也许在几个CPU时钟周期后,就可以得到锁。如果这样,简单粗暴地挂起线程可能是一种得不偿失的操作。因此,系统会进行一次赌注:它会假设在不久的将来,线程可以得到这把锁。因此,虚拟机会让当前线程做几个空循环,在经过若干次循环后,如果可以得到锁,那么就顺利进入临界区。如果还不能得到锁,才会真实地将线程在操作系统层面挂起。

2.4 锁清除

锁消除是一种更彻底的锁优化。Java虚拟机在JIT编译时,通过对上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。
例如,在一个不可能存在并发竞争的场合使用Vector,而Vector内部使用了Synchronized请求锁。比如下面的代码:

public String[] createStrings() {
    Vector v = new Vector();
    for(int i=0; i<100; i++) {
        v.add(Integer.toString(i));
    }
    return v.toArray(new String[]{});
}

v属于线程私有数据,不可能被其它线程访问。这种情况下,Vector内部所有加锁同步都是没有必要的,虚拟机检测到这种情况就会将这些无用的锁清除掉。

三、人手一支笔:ThreadLocal

除了控制资源的访问外,我们可以通过增加资源来保证所有对象的线程安全。比如100个人填写个人信息表,如果只有一支笔,那么大家都得排队,如果准备100支笔,这样人手一支笔,就可以很快完成填写信息。

如果说锁是第一种思路,ThreadLocal就是第二种思路。

详细说明见:https://blog.csdn.net/demon7552003/article/details/90542961

四、无锁


对于并发控制而言,锁是一种悲观的策略。它总是假设每一次的临界区操作会产生冲突,因此,必须对每次操作都小心翼翼。如果有多个线程同时需要访问临界区资源,就宁可牺牲性能让线程进行等待,所以说锁会阻塞线程执行。
无锁是一种乐观的策略,它会假设对资源的访问是没有冲突的。既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿的状态下持续执行。那遇到冲突怎么办呢?无锁的策略使用一种叫做比较交换的技术(CAS Compare And Swap)来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。

无锁的好处:
第一,在高并发的情况下,它比有锁的程序拥有更好的性能
第二,它天生就是死锁免疫的
 

1.与众不同的并发策略:比较交换(CAS)

与锁相比,使用比较交换(下文简称CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。
CAS算法的过程是这样:它包含三个参数CAS(V,E,N)V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
简单地说,CAS需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,那说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。
在硬件层面,大部分的现代处理器都已经支持原子化的CAS指令。在JDK 5.0以后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构,并且,这种操作在虚拟机中可以说是无处不在。

2.无锁的线程安全整数:AtomicInteger


为了让Java程序员能够受益于CAS等CPU指令,JDK并发包中有一个atomic包,里面实现了一些直接使用CAS操作的线程安全的类型。其中,最常用的一个类,应该就是AtomicInteger。你可以把它看做是一个整数。但是与Inte-ger不同,它是可变的,并且是线程安全的。对其进行修改等任何操作,都是用CAS指令进行的。这里简单列举一下AtomicInteger的一些主要方法,对于其他原子类,操作也是非常类似的:


public final int get()//取得当前值
public final void set(int newValue)//设置当前值
public final int getAndSet(int newValue)//设置新值,并返回旧值
public final boolean compareAndSet(int expect, int u)//如果当前值为expect,则设置为u
public final int getAndIncrement()//当前值加1,返回旧值
public final int getAndDecrement()//当前值减1,返回旧值
public final int getAndAdd(int delta)//当前值增加delta,返回旧值
public final int incrementAndGet()//当前值加1,返回新值
public final int decrementAndGet()//当前值减1,返回新值
public final int addAndGet(int delta)//当前值增加delta,返回新值

就内部实现上来说,AtomicInteger中保存一个核心字段:

private volatile int value;                 //它就代表了AtomicInteger的当前实际取值

 此外还有一个:

private static final long valueOffset;

它保存着value字段在AtomicInteger对象中的偏移量。后面你会看到,这个偏移量是实现AtomicInteger的关键。

和AtomicInteger类似的类还有AtomicLong用来代表long型,AtomicBoolean表示boolean型,AtomicReference表示对象引用。   

3.Java中的指针:Unsafe类

在AtomicInteger中compareAndSet()方法:

public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}


有一个特殊的变量unsafe,它是sun.misc.Unsafe类型。这个类封装了一些不安全的操作,类似C语言中指针的操作。

public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);


方法是一个navtive方法,它的参数含义是:

  • o:为给定的对象
  • offset:为对象内的偏移量(其实就是一个字段到对象头部的偏移量,通过这个偏移量可以快速定位字段
  • expected:表示期望值
  • x:要设置的值。如果指定的字段的值等于expected,那么就会把它设置为x。

不难看出,compareAndSwapInt()方法的内部,必然是使用CAS原子指令来完成的

此外,Unsafe类还提供了一些方法,主要有以下几个(以Int操作为例,其他数据类型是类似的):

public native int getInt(Object o, long offset);//获得给定对象偏移量上的int值
public native void putInt(Object o, long offset, int x);//设置给定对象偏移量上的int值
public native long objectFieldOffset(Field f);//获得字段在对象中的偏移量
public native void putIntVolatile(Object o, long offset, int x);//设置给定对象的int值,使用volatile语义
public native int  getIntVolatile(Object o, long offset);//获得给定对象对象的int值,使用volatile语义
public native void putOrderedInt(Object o, long offset, int x);//和putIntVolatile()一样,但是它要求被操作字段就是volatile类型的

这里就可以看到,虽然Java抛弃了指针。但是在关键时刻,类似指针的技术还是必不可少的。这里底层的Unsafe实现就是最好的例子。但是很不幸,JDK的开发人员并不希望大家使用这个类。获得Unsafe实例的方法是调动其工厂方法getUnsafe()。但是,它的实现却是这样:


@CallerSensitive
public static Unsafe getUnsafe() {
Class var0 = Reflection.getCallerClass();
if(!VM.isSystemDomainLoader(var0.getClassLoader())) {
    throw new SecurityException("Unsafe");
} else {
    return theUnsafe;
}
}


注意加粗部分的代码,它会检查调用getUnsafe()函数的类,如果这个类的ClassLoader不为null,就直接抛出异常,拒绝工作。因此,这也使得我们自己的应用程序无法直接使用Unsafe类。它是一个JDK内部使用的专属类

注意:根据Java类加载器的工作原理,应用程序的类由App Loader加载。而系统核心类,如rt.jar中的类由Bootstrap类加载器加载。Bootstrap加载器没有Java对象的对象,因此试图获得这个类加载器会返回null。所以,当一个类的类加载器为null时,说明它是由Bootstrap加载的,而这个类也极有可能是rt.jar中的类。

4.无锁的对象引用:AtomicReference

AtomicReference和AtomicInteger非常类似,不同之处就在于AtomicInteger是对整数的封装,而AtomicReference则对应普通的对象引用。也就是它可以保证你在修改对象引用时的线程安全性。在介绍AtomicReference的同时,我希望同时提出一个有关原子操作的逻辑上的不足。
之前我们说过,线程判断被修改对象是否可以正确写入的条件是对象的当前值和期望值是否一致。这个逻辑从一般意义上来说是正确的。但有可能出现一个小小的例外,就是当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过这两次修改后,对象的值又恢复为旧值。这样,当前线程就无法正确判断这个对象究竟是否被修改过,如图:

锁的优化及注意事项_第1张图片

一般来说,发生这种情况的概率很小。而且即使发生了,可能也不是什么大问题。比如,我们只是简单得要做一个数值加法,即使在我取得期望值后,这个数字被不断的修改,只要它最终改回了我的期望值,我的加法计算就不会出错。也就是说,当你修改的对象没有过程的状态信息,所有的信息都只保存于对象的数值本身。
但是,在现实中,还可能存在另外一种场景。就是我们是否能修改对象的值,不仅取决于当前值,还和对象的过程变化有关,这时,AtomicReference就无能为力了。
打一个比方,如果有一家蛋糕店,为了挽留客户,绝对为贵宾卡里余额小于20元的客户一次性赠送20元,刺激消费者充值和消费。但条件是,每一位客户只能被赠送一次
现在,我们就来模拟这个场景,为了演示AtomicReference,我在这里使用AtomicReference实现这个功能。首先,我们模拟用户账户余额。

static AtomicReference money=newAtomicReference();
// 设置账户初始值小于20,显然这是一个需要被充值的账户
money.set(19);

接着,我们需要若干个后台线程,它们不断扫描数据,并为满足条件的客户充值。

 

01 //模拟多个线程同时更新后台数据库,为用户充值
02 for(int i = 0 ; i < 3 ; i++) {            
03     new Thread(){
04         publicvoid run() {
05            while(true){
06                while(true){
07                    Integer m=money.get();
08                    if(m<20){
09                        if(money.compareAndSet(m, m+20)){
10                  System.out.println("余额小于20元,充值成功,余额:"+money.get()+"元");
11                             break;
12                        }
13                    }else{
14                        //System.out.println("余额大于20元,无需充值");
15                         break ;
16                    }
17                 }
18             }
19         }
20     }.start();
21 }

上述代码第8行,判断用户余额并给予赠予金额。如果已经被其他用户处理,那么当前线程就会失败。因此,可以确保用户只会被充值一次。此时,如果很不幸的,用户正好正在进行消费,就在赠予金额到账的同时,他进行了一次消费,使得总金额又小于20元,并且正好累计消费了20元。使得消费、赠予后的金额等于消费前、赠予前的金额。这时,后台的赠予进程就会误以为这个账户还没有赠予,所以,存在被多次赠予的可能。下面,模拟了这个消费线程:

01 //用户消费线程,模拟消费行为
02 new Thread() {
03     public voidrun() {
04         for(inti=0;i<100;i++){
05            while(true){
06                Integer m=money.get();
07                 if(m>10){
08                    System.out.println("大于10元");
09                    if(money.compareAndSet(m, m-10)){
10                        System.out.println("成功消费10元,余额:"+money.get());
11                        break;
12                    }
13                }else{
14                    System.out.println("没有足够的金额");
15                    break;
16                 }
17             }
18             try{Thread.sleep(100);} catch (InterruptedException e) {}
19         }
20     }
21 }.start();

上述代码中,消费者只要贵宾卡里的钱大于10元,就会立即进行一次10元的消费。执行上述程序,得到的输出如下:

余额小于20元,充值成功,余额:39元
大于10元
成功消费10元,余额:29
大于10元
成功消费10元,余额:19
余额小于20元,充值成功,余额:39元
大于10元
成功消费10元,余额:29
大于10元
成功消费10元,余额:39
余额小于20元,充值成功,余额:39元

从这一段输出中,可以看到,这个账户被先后反复多次充值。其原因正是因为账户余额被反复修改,修改后的值等于原有的数值。使得CAS操作无法正确判断当前数据状态。
虽然说这种情况出现的概率不大,但是依然是有可能的出现的。因此,当业务上确实可能出现这种情况时,我们也必须多加防范。体贴的JDK也已经为我们考虑到了这种情况,使用AtomicStampedReference就可以很好的解决这个问题。

5.带有时间戳的对象引用:AtomicStampedReference

AtomicReference无法解决上述问题的根本因为是对象在修改过程中,丢失了状态信息。
AtomicStampedReference,它内部不仅维护了对象值,还维护了一个时间戳(我这里把它称为时间戳,实际上它可以使任何一个整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。
当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。

AtomicStampedReference的几个API在AtomicReference的基础上新增了有关时间戳的信息:

public boolean compareAndSet(V expectedReference,VnewReference,int expectedStamp,int newStamp)//比较设置 参数依次为:期望值 写入新值 期望时间戳 新时间戳
public V getReference()//获得当前对象引用
public int getStamp()//获得当前时间戳
public void set(V newReference, int newStamp)//设置当前对象引用和时间戳


有了AtomicStampedReference这个法宝,我们就再也不用担心对象被写坏啦!现在,就让我们使用AtomicStampedReference在修正那个贵宾卡充值的问题的:

01 public class AtomicStampedReferenceDemo {
02 static AtomicStampedReference money=new AtomicStampedReference(19,0);
03    public staticvoid main(String[] args) {
04        //模拟多个线程同时更新后台数据库,为用户充值
05        for(int i = 0 ; i < 3 ; i++) {
06            final int timestamp=money.getStamp();
07             newThread() {  
08                public void run() {
09                    while(true){
10                        while(true){
11                             Integerm=money.getReference();
12                             if(m<20){
13                          if(money.compareAndSet(m,m+20,timestamp,timestamp+1)){
14           System.out.println("余额小于20元,充值成功,余额:"+money.getReference()+"元");
15                                     break;
16                                 }
17                             }else{
18                                //System.out.println("余额大于20元,无需充值");
19                                 break ;
20                             }
21                        }
22                    }
23                }
24            }.start();
25         }
26        
27        //用户消费线程,模拟消费行为
28        new Thread() {
29             publicvoid run() {
30                for(int i=0;i<100;i++){
31                    while(true){
32                        int timestamp=money.getStamp();
33                        Integer m=money.getReference();
34                        if(m>10){
35                             System.out.println("大于10元");
36                          if(money.compareAndSet(m, m-10,timestamp,timestamp+1)){
37                       System.out.println("成功消费10元,余额:"+money.getReference());
38                                 break;
39                             }
40                        }else{
41                            System.out.println("没有足够的金额");
42                             break;
43                        }
44                    }
45                    try {Thread.sleep(100);} catch (InterruptedException e) {}
46                 }
47             }
48        }.start();
49    }
50 }

第2行,我们使用AtomicStampedReference代替原来的AtomicReference。第6行获得账户的时间戳。后续的赠予操作以这个时间戳为依据。如果赠予成功(13行),则修改时间戳。使得系统不可能发生二次赠予的情况。消费线程也是类似,每次操作,都使得时间戳加1(36行),使之不可能重复。

执行上述代码,可以得到以下输出:

余额小于20元,充值成功,余额:39元
大于10元
成功消费10元,余额:29
大于10元
成功消费10元,余额:19
大于10元
成功消费10元,余额:9
没有足够的金额

可以看到,账户只被赠予了一次。

6.数组也能无锁:AtomicIntegerArray

除了提供基本数据类型外,JDK还为我们准备了数组等复合结构。当前可用的原子数组有:AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,分别表示整数数组、long型数组和普通的对象数组。

这里以AtomicIntegerArray为例,展示原子数组的使用方式。
AtomicIntegerArray本质上是对int[]类型的封装,使用Unsafe类通过CAS的方式控制int[]在多线程下的安全性。它提供了以下几个核心API:

public final int get(int i)//获得数组第i个下标的元素
public final int length()//获得数组的长度
public final int getAndSet(int i, int newValue)//将数组第i个下标设置为newValue,并返回旧的值
public final boolean compareAndSet(int i, int expect, int update)//进行CAS操作,如果第i个下标的元素等于expect,则设置为update,设置成功返回true
public final int getAndIncrement(int i)//将第i个下标的元素加1
public final int getAndDecrement(int i)//将第i个下标的元素减1
public final int getAndAdd(int i, int delta)//将第i个下标的元素增加delta(delta可以是负数)

 下面给出一个简单的示例,展示AtomicIntegerArray使用:

01 public class AtomicIntegerArrayDemo {  
02    staticAtomicIntegerArray arr = new AtomicIntegerArray(10);  
03     public staticclass AddThread implements Runnable{  
04         publicvoid run(){  
05            for(intk=0;k<10000;k++)  
06                 arr.getAndIncrement(k%arr.length());  
07         }  
08     }  
09    public staticvoid main(String[] args) throws InterruptedException {  
10         Thread[]ts=new Thread[10];  
11         for(intk=0;k<10;k++){  
12            ts[k]=new Thread(new AddThread());  
13         }  
14         for(intk=0;k<10;k++){ts[k].start();}  
15         for(intk=0;k<10;k++){ts[k].join();}  
16         System.out.println(arr);  
17    }  
18 }  


上述代码第2行,申明了一个内含10个元素的数组。第3行定义的线程对数组内10个元素进行累加操作,每个元素各加1000次。第11行,开启10个这样的线程。因此,可以预测,如果线程安全,数组内10个元素的值必然都是10000。反之,如果线程不安全,则部分或者全部数值会小于10000。

程序的输出结果如下:

    [10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000,10000, 10000]  

这说明AtomicIntegerArray确实合理地保证了数组的线程安全性。

7.让普通变量也享受原子操作:AtomicIntegerFieldUpdater


将普通变量也变成线性安全的。
在原子包里还有一个实用的工具类AtomicIn-tegerFieldUpdater。它可以让你在不改动(或者极少改动)原有代码的基础上,让普通的变量也享受CAS操作带来的线程安全性,这样你可以修改极少的代码,来获得线程安全的保证。
根据数据类型不同,这个Updater有三种,分别是AtomicIntegerFieldUpdater、AtomicLong-FieldUpdater和AtomicReferenceFieldUpdater。顾名思义,它们分别可以对int、long和普通对象进行CAS修改。

现在来思考这么一个场景。假设某地要进行一次选举。现在模拟这个机票场景,如果选民投了候选人一票,就记为1,否则记为0。最终的选票显然就是所有数据的简单求和。
 

public class AtomicIntegerFieldUpdaterDemo {
    public static class Candidate{
        int id;
        volatile int score;
    }
    public final static AtomicIntegerFieldUpdater<Candidate> scoreUpdater 
        = AtomicIntegerFieldUpdater.newUpdater(Candidate.class, "score");
    //检查Updater是否工作正确
    public static AtomicInteger allScore=new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        final Candidate stu=new Candidate();
        Thread[] t=new Thread[10000];
        for(int i = 0 ; i < 10000 ; i++) {  
            t[i]=new Thread() {  
                public void run() {  
                    if(Math.random()>0.4){
                        scoreUpdater.incrementAndGet(stu);
                        allScore.incrementAndGet();
                    }
                }  
            };
            t[i].start();
        }  
        for(int i = 0 ; i < 10000 ; i++) {  t[i].join();}
        System.out.println("score="+stu.score);
        System.out.println("allScore="+allScore);
    }
}


上述代码模拟了这个计票场景。候选人的得票数量记录在Candidate.score中。注意,它是一个普通的volatile变量。而volatile变量并不是线程安全的。第6~7行定义了AtomicIntegerFieldUpdater实例,用来对Candidate.score进行写入。而后续的allScore我们用来检查AtomicIntegerFieldUpdater的正确性。如果AtomicIntegerFieldUpdater真的保证了线程安全,那么最终Candidate.score和allScore的值必然是相等的。否则,就说明AtomicIntegerFieldUpdater根本没有确保线程安全的写入。第12~21行模拟了计票过程,这里假设有大约60%的人投赞成票,并且投票是随机进行的。第17行使用Updater修改Candidate.score(这里应该是线程安全的),第18行使用AtomicInteger计数,作为参考基准。
大家如果运行这段程序,不难发现,最终的Candidate.score总是和allScore绝对相等。这说明AtomicIntegerFieldUpdater很好地保证了Candidate.score的线程安全。

 

虽然AtomicIntegerFieldUpdater很好用,但是还是有几个注意事项:
第一,Updater只能修改它可见范围内的变量。因为Updater使用反射得到这个变量。如果变量不可见,就会出错。比如如果score申明为private,就是不可行的。

  • 字段的描述类型(修饰符public/protected/default/private)是与调用者与操作对象字段的关系一致。也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作。但是对于父类的字段,子类是不能直接操作的,尽管子类可以访问父类的字段。
  • 只能是可修改变量,不能使final变量,因为final的语义就是不可修改。实际上final的语义和volatile是有冲突的,这两个关键字不能同时存在

第二,为了确保变量被正确的读取,它必须是volatile类型的。如果我们原有代码中未申明这个类型,那么简单地申明一下就行,这不会引起什么问题。
第三,由于CAS操作会通过对象实例中的偏移量直接进行赋值,因此,它不支持static字段(Unsafe. objectFieldOffset()不支持静态变量)。  

8.挑战无锁算法:无锁的Vector实现

我们已经比较完整得介绍了有关无锁的概念和使用方法。相对于有锁的方法,使用无锁的方式编程更加考验一个程序员的耐心和智力。但是,无锁带来的好处也是显而易见的,第一,在高并发的情况下,它比有锁的程序拥有更好的性能;第二,它天生就是死锁免疫的。就凭借这2个优势,就值得我们冒险尝试使用无锁的并发。
这里,我想向大家介绍一种使用无锁方式实现的Vector。通过这个案例,我们可以更加深刻地认识无锁的算法,同时也可以学习一下有关Vector实现的细节和算法技巧。(在本例中,讲述的无锁Vector来自于amino并发包)
我们将这个无锁的Vector称为LockFreeVector。它的特点是可以根据需求动态扩展其内部空间。在这里,我们使用二维数组来表示LockFreeVector的内部存储,如下:

private final AtomicReferenceArray> buckets;

变量buckets存放所有的内部元素。从定义上看,它是一个保存着数组的数组,也就是通常的二维数组。特别之处在于这些数组都是使用CAS的原子数组。为什么使用二维数组去实现一个一维的Vector呢?这是为了将来Vector进行动态扩展时可以更加方便。我们知道,AtomicReferenceArray内部使用Object[]来进行实际数据的存储,这使得动态空间增加特别的麻烦,因此使用二维数组的好处就是为将来增加新的元素。
此外,为了更有序的读写数组,定义一个称为Descriptor的元素。它的作用是使用CAS操作写入新数据。

static class Descriptor {
    public int size;
    volatile WriteDescriptor writeop;
    public Descriptor(int size, WriteDescriptor writeop) {
        this.size = size;
        this.writeop = writeop;
    }
    public void completeWrite() {
        WriteDescriptor tmpOp = writeop;
        if (tmpOp != null) {
            tmpOp.doIt();
            writeop = null; // this is safe since all write to writeop use
            // null as r_value.
        }
    }
}
static class WriteDescriptor {
    public E oldV;
    public E newV;
    public AtomicReferenceArray addr;
    public int addr_ind;
    public WriteDescriptor(AtomicReferenceArray addr, int addr_ind, E oldV, E newV) {
        this.addr = addr;
        this.addr_ind = addr_ind;
        this.oldV = oldV;
        this.newV = newV;
    }
    public void doIt() {
        addr.compareAndSet(addr_ind, oldV, newV);
    }
}

上述代码第4行定义的Descriptor构造函数接收2个参数,第一个为整个Vector的长度,第2个为一个writer。最终,写入数据是通过writer进行的(通过completeWrite()方法)。
第24行,WriteDescriptor的构造函数接收4个参数。第一个参数addr表示要修改的原子数组,第二个参数为要写入的数组索引位置,第三个oldV为期望值,第4个newV为需要写入的值。
在构造LockFreeVector时,显然需要将buckets和descriptor进行初始化。

public LockFreeVector() {
    buckets = new AtomicReferenceArray>(N_BUCKET);
    buckets.set(0, new AtomicReferenceArray(FIRST_BUCKET_SIZE));
    descriptor = new AtomicReference>(new Descriptor(0,null));
}


在这里N_BUCKET为30,也就是说这个buckets里面可以存放一共30个数组(由于数组无法动态增长,因此数组总数也就不能超过30个)。并且将第一个数组的大小为FIRST_BUCKET_SIZE为8。到这里,大家可能会有一个疑问,如果每个数组8个元素,一共30个数组,那岂不是一共只能存放240个元素吗?
如果大家了解JDK内的Vector实现,应该知道,Vector在进行空间增长时,默认情况下,每次都会将总容量翻倍。因此,这里也借鉴类似的思想,每次空间扩张,新的数组的大小为原来的2倍(即每次空间扩展都启用一个新的数组),因此,第一个数组为8,第2个就是16,第3个就是32。以此类推,因此30个数组可以支持的总元素达到。
这数值已经超过了2^33,即在80亿以上。因此,可以满足一般的应用。
当有元素需要加入LockFreeVector时,使用一个名为push_back()的方法,将元素压入Vector最后一个位置。这个操作显然就是LockFreeVector的最为核心的方法,也是最能体现CAS使用特点的方法,它的实现如下:

public void push_back(E e) {
    Descriptor desc;
    Descriptor newd;
    do {
             desc = descriptor.get();
             desc.completeWrite();
             int pos = desc.size + FIRST_BUCKET_SIZE;
             int zeroNumPos = Integer.numberOfLeadingZeros(pos);
             int bucketInd = zeroNumFirst – zeroNumPos;
             if (buckets.get(bucketInd) == null) {
                      int newLen = 2 * buckets.get(bucketInd – 1).length();
                      if (debug)
                                System.out.println(“New Length is:” + newLen);
                      buckets.compareAndSet(bucketInd, null,
                                         new AtomicReferenceArray(newLen));
             }
             int idx = (0×80000000>>>zeroNumPos) ^ pos;
             newd = new Descriptor(desc.size + 1, new WriteDescriptor(
                                buckets.get(bucketInd), idx, null, e));
    } while (!descriptor.compareAndSet(desc, newd));
    descriptor.get().completeWrite();
 }


可以看到,这个方法主体部分是一个do-while循环,用来不断尝试对descriptor的设置。也就是通过CAS保证了descriptor的一致性和安全性。在第23行,使用descriptor将数据真正地写入数组中。这个descriptor写入的数据由20~21行构造的WriteDescriptor决定。
在循环最开始(第5行),使用descriptor先将数据写入数组,是为了防止上一个线程设置完descriptor后(22行),还没来得及执行23行的写入,因此,做一次预防性的操作。
因为限制要将元素e压入Vector,因此,我们必须首先知道这个e应该放在哪个位置。由于目前使用了2维数组,因此我们自然需要知道e所在哪个数组(buckets中的下标位置)和数组中的下标。
第8到10行通过当前Vector的大小(desc.size),计算新的元素应该落入哪个数组。这里使用了位运算进行计算。这几行代码看起来也许你会觉得有些奇怪,我的解释如下:
之前说过,LockFreeVector每次都会成倍的扩容。它的第1个数组长度为8,第2个就是16,第3个就是32,依次类推。它们的二进制表示就是:

00000000 00000000 00000000 00001000:第一个数组大小,28个前导零
00000000 00000000 00000000 00010000:第二个数组大小,27个前导零
00000000 00000000 00000000 00100000:第三个数组大小,26个前导零
00000000 00000000 00000000 01000000:第四个数组大小,25个前导零

他们之和就是整个LockFreeVector的总大小,因此,如果每一个数组都恰好填满,那么总大小应该类似这样的数值(以4个数组填满为例):
00000000 00000000 00000000 01111000:4个数组都恰好填满时的大小
导致这个数字进位的最小条件,就是加上二进制的1000。而这个数字正好是8(FIRST_BUCKET_SIZE就是8)。这就是第8行代码的意义。它可以使得数组大小发生一次二进制的进位(如果不进位说明还在第一个数组中),进位后前导零的数量就会发生变化。而元素所在的数组,和pos(第8行定义的变量)的前导零直接相关。每进行一次数组扩容,它的前导零就会减1。如果从来没有扩容过,它的前导零就是28个。以后,逐级减1。这就是第9行获得pos前导零的原因。第10行,通过pos的前导零可以立即定位使用哪个数组(也就是得到了bucketInd的值)。
第11行,判断这个数组是否存在。如果不存在,则创建这个数组,大小为前1个数组的2倍,并把它设置到buckets中。
接着再看一下元素没有恰好填满的情况:

00000000 00000000 00000000 00001000:第一个数组大小,28个前导零
00000000 00000000 00000000 00010000:第二个数组大小,27个前导零
00000000 00000000 00000000 00100000:第三个数组大小,26个前导零
00000000 00000000 00000000 00000001:第四个数组大小,只有一个元素


那么总大小就是:

00000000 00000000 00000000 00111001:元素总个数

总个数加上二进制1000后,得到:

00000000 00000000 00000000 01000001:

显然,通过前导零可以定位到第4个数组。而剩余位,显然就表示元素在当前数组内的偏移量(也就是数组下标)。根据这个理论,我们就就可以通过pos计算这个元素应该放在给定数组的哪个位置。通过第19行代码,获得pos的除了第1位数字1以外的其他位的数值。因此,pos的前导零可以表示元素所在的数组,而pos的后面几位,则表示元素所在这个数组中的位置。由此,第19行代码就取得了元素的所在位置idx。
到此,我们就已经得到新元素位置的全部信息,剩下的就是将这些信息传递给Descriptor让它在给定的位置把元素e安置上去即可。这里,就通过CAS操作,保证写入正确性。
下面来看一下get()操作的实现:

@Override
 public E get(int index) {
      int pos = index + FIRST_BUCKET_SIZE;
      int zeroNumPos = Integer.numberOfLeadingZeros(pos);
      int bucketInd = zeroNumFirst – zeroNumPos;
      int idx = (0×80000000>>>zeroNumPos) ^ pos;
      return buckets.get(bucketInd).get(idx);
 }


在get()的实现中,3~6行使用了相同的算法获得所需元素的数组以及数组中的索引下标。这里简单的通过buckets定位到对应的元素即可。
这样,对于Vector来说2个重要的方法就已经实现了。其他方法也是非常类似的,这里就不再详细讨论了。

 

 

9.SynchronousQueue的实现

在对线程池的介绍中,提到了一个非常特殊的等待队列SynchronousQueue。SynchronousQueue的容量为0,任何一个对SynchronousQueue的写需要等待一个对SynchronousQueue的读,因此,SynchronousQueue与其说是一个队列,不如说是一个数据交换通道。

对SynchronousQueue来说,它将put()和take()两个功能截然不同的操作抽象为一个共通的方法Transferer.transfer()。

Object transfer(Object e, boolean timed, long nanos)

当参数e为非空时,表示当前操作传递给一个消费者,如果为空,则表示当前操作需要请求一个数据。timed参数决定是否存在timeout时间,nanos决定了timeout的时长。如果返回值非空,则表示数据已经接受或者正常提供,如果为空,则表示失败(超时或者中断)。
SynchronousQueue内部会维护一个线程等待队列。等待队列中会保存等待线程以及相关数据的信息。比如,生产者将数据放入Syn-chronousQueue时,如果没有消费者接收,那么数据本身和线程对象都会打包在队列中等待(因为SynchronousQueue容积为0,没有数据可以正常放入)。
Transferer.transfer()函数的实现是Syn-chronousQueue的核心,它大体上分为三个步骤:

1. 如果等待队列为空,或者队列中节点的类型和本次操作是一致的,那么将当前操作压入队列等待。比如,等待队列中是读线程等待,本次操作也是读,因此这两个读都需要等待。进入等待队列的线程可能会被挂起,它们会等待一个“匹配”操作。
2. 如果等待队列中的元素和本次操作是互补的(比如等待操作是读,而本次操作是写),那么就插入一个“完成”状态的节点,并且让他“匹配”到一个等待节点上。接着弹出这两个节点,并且使得对应的两个线程继续执行。
3. 如果线程发现等待队列的节点就是“完成”节点,那么帮助这个节点完成任务。其流程和步骤2是一致的。

 

 

 

你可能感兴趣的:(Java并发编程,Java基础知识)