Java封神之旅-深入理解Java中的同步器工具类

深入理解Java中的同步器工具类

同步器主要是用于控制多线程对某个共享资源的访问,控制多个线程中哪个线程优先获取数据,哪个线程要等待才能获取数据,并保证每个线程获取的数据是正确的。控制获取数据的方式有两种,第1种是抢占式,即高优先级的线程可以插队先获取共享数据资源,另一种是排队式,即每个线程按排队依次访问共享资源。
有两种实现方式:第一种,AQS。AQS是Java语言自己实现数据多线程访问的方式。请先看完这篇文章AQS再来看这些同步类工具会轻而易举。另外一种是没有利用AQS,而是利用底层的CAS CPU指令来实现。

同步工具类主要分两种实现,第一种是基于AQS实现,如Semaphore、CountDownLatch和CyclicBarrier。另一种同步工具跳过AQS,自己采用volatile state CAS + LockSupport.park/unpark实现的,Exchanger和Phaser是采用这种方式。AQS底层也是采用这种方式。

Semaphore

信号量属于syncronized的升级版。syncronized在同一个时刻只允许一个线程进入某段代码,但Semaphore能允许多个线程进入某段代码执行。多个线程默认是不公平竞争。当然也可以通过方法new Semaphore(int permits, boolean fair)设置为公平竞争,当许可数量permits=1可认为是互斥锁,当permit>1是共享锁。当为非公平竞争时,线程之间是抢占式的。当公平策略为true时,则多个线程按照FIFO顺序获取许可。​

示例

先来段简单代码,从下面代码运行结果可以看到,初始化通路为2,而acquire()每次占用一个通路,所以有两个线程能同时被调度。如果每次运行一个线程占用2个通路,那每次只能有一个线程被调度。有兴趣的童鞋可以改动下acquire()和release()方法中的通道数为2。运行请求许可acquire(int permit)方法会阻塞,直到有指定的许可可以获取,释放许可require(int permit)方法增加指定个许可。Semaphore类内部维持了一个计数器,每次请求一个许可,计数器减1,每次释放一个许可,则计数器加1。当计数器为0,则阻塞获取许可,也就是阻塞任务的提交。

public class SemaphoreTest {
    static SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    static ExecutorService executorService = Executors.newCachedThreadPool();
    static Semaphore semaphore = new Semaphore(2);//初始化2个通路
    public static void main(String [] args) {
        IntStream.range(0,10).forEach(i -> executorService.submit(new Runnable() {
            @Override
            public void run() {
                try {
                    //获取许可
                    semaphore.acquire(1);//等同于semaphore.acquire()
                    System.out.println(Thread.currentThread().getName() + " Start At " +  getFormatTimeStr());
                    Thread.sleep(new Random().nextInt(5000));//模拟随机执行时长
                    System.out.println(Thread.currentThread().getName() + " End At " +  getFormatTimeStr());
                    //释放,不释放将会一直阻塞其他线程进入
                    semaphore.release(1);//等同于semaphore.release()
                }catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }));
        executorService.shutdown();
    }

    public static String getFormatTimeStr() {
        return sf.format(new Date());
    }
}

源码剖析

接下来揭开它的神秘面纱。首先需要了解下类图。

在这里插入图片描述

通过该类图可以发现,Semaphore类通过内部类Sync继承AQS实现。所有实现交由AQS实现。而AQS在前面已经讲解过,它需要子类实现它的保护类方法tryAcquireShared()。但AQS的抽象子类Sync并未自己实现tryAcquireShared()方法。而是交由子类非公平同步类NonfairSync及公平同步类FairSync实现。在公平同步类FairSync中(如下图),与ReentrantLock一样,先判断同步队列中是否还有待处理的等待线程,有则直接返回-1表示失败。

protected int tryAcquireShared(int acquires) {
            for (;;) {
                if (hasQueuedPredecessors())//判断NODE节点是否队头等于队尾
                    return -1;
                int available = getState();//获取可用的许可数
                int remaining = available - acquires;
                if (remaining < 0 ||
                compareAndSetState(available, remaining))//如果剩余许可大于0,通过CAS设置许可数
                    return remaining;//返回可用许可
            }
}
NonfairSync(int permits) {
            super(permits);
}

非公平同步队列直接采用父类默认的同步对列方法。

使用场景

Semaphore通常用于控制并发访问某个资源的数量,或者同时执行某个指定操作的数量。还能用于对某个资源池的限制或者对容器加上边界。在RocketMQ中,当用异步方式发送数据时,用信号量Semaphore控制生产者发送到代理Blocker的数量,防止本地缓存过多请求。同时也限制服务端因DDOS攻击而挂掉。

// 信号量,Oneway情况会使用,防止本地Netty缓存请求过多
protected final Semaphore semaphoreOneway;
// 信号量,异步调用情况会使用,防止本地Netty缓存请求过多
protected final Semaphore semaphoreAsync;
public NettyRemotingAbstract(final int permitsOneway, final int permitsAsync) {
        this.semaphoreOneway = new Semaphore(permitsOneway, true);
        this.semaphoreAsync = new Semaphore(permitsAsync, true);//公平竞争方式
}
//使用
public void invokeAsyncImpl(final Channel channel, final RemotingCommand request,
            final long timeoutMillis, final InvokeCallback invokeCallback) throws InterruptedException,
            RemotingTooMuchRequestException, RemotingTimeoutException, RemotingSendRequestException {
        boolean acquired = this.semaphoreAsync.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS);//获取信号
        if (acquired) {
           ...
        }
 }

在Dubbo中的类ExecuteLimitFilter,也有用信号量实现对服务端的并发线程数的控制,如下图。但是在高版本中,已经修改为AtomicInteger和AtomicLong控制并发线程数。显然,原子操作效率会更高。有兴趣的读者可以参考RpcStatus类。

Semaphore executesLimit = null;
 boolean acquireResult = false;
 int max = url.getMethodParameter(methodName, Constants.EXECUTES_KEY, 0);
 if (max > 0) {
      RpcStatus count = RpcStatus.getStatus(url, invocation.getMethodName());
      executesLimit = count.getSemaphore(max);
      if(executesLimit != null && !(acquireResult = executesLimit.tryAcquire())) {
                throw new RpcException("Failed to invoke method " + invocation.getMethodName() + " in provider " + url + ", cause: The service using threads greater than <dubbo:service executes=\"" + max + "\" /> limited.");
       }
 }

CountDownLatch

倒计时门闩,主要用于多个线程需要同时完成一项任务的场景。当该任务划分非多个子任务,每个子任务由各自的线程完成该子任务,优先完成子任务的线程都在"门口"等待。由最后完成的线程关上门锁。

示例

下面举个跑步的例子,只有等参赛者全部跑完,才能计算排名。用10个子线程模拟10个参赛者,等子线程全部执行完,主线程开始统计排名。通过new CountDownLatch(10)新建10个子任务,最终会初始化前面谈论的AQS中的state为值。每个任务由单个线程去执行。执行完后通过latch.countDown()方法将CountDownLatch中的计数器减少1,该线程则阻塞在latch.await()方法处。当所有的线程执行完,计数器state则为0,此时,最后一个线程将触发闭锁动作。

public class CountDownLatchTest {
    public static void main(String [] args) {
        CountDownLatch latch = new CountDownLatch(10);
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        Map<Integer, Long> timeMap = new HashMap();
        IntStream.range(0,10).forEach(i -> executorService.submit(() -> {
            Long startTime = System.currentTimeMillis();
            System.out.println("参赛者 " + i + " 开始出发");
            try {
                Thread.sleep(ThreadLocalRandom.current().nextLong(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            timeMap.put(i, System.currentTimeMillis()- startTime);
            System.out.println("参赛者 " + i + " 已到达终点");
            latch.countDown();
        }));

        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("10个参赛者已经执行完毕!开始计算排名");
        Map<Integer, Long> orderMap = Maps.newLinkedHashMap();
        timeMap.entrySet().stream().sorted(Map.Entry.comparingByValue()).forEach(x -> orderMap.put(x.getKey(), x.getValue()));
        System.out.println(orderMap);
    }
}

源码剖析

为什么会阻塞在await方法处呢,下面分析它的内部逻辑。直接调用了AQS的acquireSharedInterruptibly方法,如果异常,则直接中断(aborting if interrupted),它至少执行去尝试一次获取锁(通过tryAcquireShared方法),如果获取失败,则线程继续排队等待,反复阻塞和解除阻塞。直到成功。而Semaphore中的acquireShared是在共享模式下CAS设置state状态,忽略中断。

public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
}

在这里插入图片描述

使用场景

在项目中,有时一个接口需要调用外部很多接口,并且前端又要要求同步返回。如果全部采用同步去调用就会发很长时间。通常的做法就是开启多个线程用异步方式调用外部接口。每个线程执行完毕就调用countDown()方法。这样在线程计数为零之前,Service的线程就会一直等待。直到我们调用完所有接口,组装数据返回前端。

CyclicBarrier

障碍器。为了完成一个大型的任务,常常需要分配好多个子任务去执行,只有当所有子任务都执行完成时候,才能执行主任务,这时候,就可以选择障碍器。与CountDownLatch的区别是,CountDownLatch的计数器减少到0后不能再次重新设置。而CyclicBarrier是可以通过reset()方法重置,还可以通过getNumberWaiting()方法获取阻塞的线程数量,isBroken()方法来判断线程是否阻塞等。可以简单理解为CyclicBarrier是CountDownLatch的高级版本。

示例

继续上面跑步的栗子。假设10个参赛者,分别参加长跑,短跑。比赛规则是先所有参赛者跑完长跑,然后一齐跑短跑。长跑+短跑总时间短的获胜。还是用十个线程表示10个参赛者。

这里建议用CurrentHashMap替代HashMap,因为有两个原因。

  1. 如果参赛者有很多个,比如10000个参赛者。采用HashMap时,由于HashMap的put方法是非线程安全的。怎么理解呢,比如A线程和B线程,通过对线程名字的hashCode都相同,即落入到同一个桶。此时A线程获取到链表(假设桶里数据是通过链表结构数据维持)头节点后,该线程的时间片用完了。此时B线程同样落到了该桶的同一个链表节点,并将数据加入了该链表。当线程A再次被调度时,它拥有过时的链表头确一无所知地将数据加入到该链表节点。此时将B线程的数据完美的覆盖。造成了数据不一致的现象。
  2. 另外一个原因hashmap的put方法中resize是线程不安全的。具体参考HashMap章节。
public class CyclicBarrierTest {
    public static void main(String[] args) {
        Map<String, Long> timeMap = new CurrentHashMap<>();//不用HashMap
        CyclicBarrier barrier = new CyclicBarrier(10, () -> {
            System.out.println("该阶段 " + Thread.currentThread().getName() + " 最后跑完");
            System.out.println("10个参赛者已经执行完毕!开始计算排名");
            Map<String, Long> orderMap = Maps.newLinkedHashMap();
            timeMap.entrySet().stream().sorted(Map.Entry.comparingByValue()).forEach(x -> orderMap.put(x.getKey(), x.getValue()));
            System.out.println(orderMap);
        });
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        IntStream.range(0, 10).forEach(i -> executorService.submit(() -> {
            Long startTime = System.currentTimeMillis();
            System.out.println("参赛者 " + Thread.currentThread().getName() + " 长跑 开始出发");
            try {
                Thread.sleep(1000 + ThreadLocalRandom.current().nextLong(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("参赛者 " + Thread.currentThread().getName() + " 长跑 已到达终点, 时间为:" + (System.currentTimeMillis() - startTime));
            timeMap.put(Thread.currentThread().getName(), System.currentTimeMillis() - startTime);

            try {
                barrier.await();//先跑完的参赛者阻塞在此,等待最后一个参赛者跑完
            } catch (Exception e) {
                e.printStackTrace();
            }

            System.out.println("参赛者 " + Thread.currentThread().getName() + " 短跑 开始出发");
            try {
                Thread.sleep(ThreadLocalRandom.current().nextLong(500));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println("参赛者 " + Thread.currentThread().getName() + " 短跑 已到达终点,时间为:" + (System.currentTimeMillis() - startTime));
            timeMap.put(Thread.currentThread().getName(), timeMap.get(Thread.currentThread().getName()).longValue() + (System.currentTimeMillis() - startTime));

            try {
                barrier.await();//同上
            } catch (Exception e) {
                e.printStackTrace();
            }
        }));
    }

}

源码剖析

那CyclicBarrier内部是怎样把所有线程阻塞,然后怎么放开的呢?这得从源码说起。关于CyclicBarrier的类图如下。通过该图,我们可以了解到有两个构造方法,其中我们用的构造函数CyclicBarrier(int parties, Runnable barrierAction),其中parties表示拦截的线程数,这里是10。第二个参数是到达屏障前的任务,这里是模拟的参赛者的跑步动作。

在这里插入图片描述

其中每个屏障用一个generation实例表示。只有当屏障被绊倒或者调用了reset()方法时才会改变generation的值。多个线程属于同一个generation。当有parties个线程到达了barrier则会导致generation的值被改变。当barrier损坏或者某个线程中断,则拥有该锁的线程通过breakBarrier()方法设置generation的值并唤醒其他被绊倒的线程。如果所有线程都到达了屏障处,则通过nextGeneration()换一代。所以,通过未到达屏障的线程计数器count和可重入条件锁ReentrantLock来阻塞所有线程。通过ReentrantLock对–count和generation操作的原子性。

private static class Generation {
        boolean broken = false;
}

private void breakBarrier() {
        generation.broken = true;//设置被绊倒
        count = parties;//设置被等待的线程为parties值
        trip.signalAll();//唤醒其他所有等待的线程
}

示例中有个重要的方法dowait()是怎样放开所有线程的?

private int dowait(boolean timed, long nanos)
        throws InterruptedException, BrokenBarrierException,
               TimeoutException {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            final Generation g = generation;

            if (g.broken)//如果该代已经broken了,抛出异常
                throw new BrokenBarrierException();

            if (Thread.interrupted()) {//当前线程被中断了,则唤醒其他线程
                breakBarrier();
                throw new InterruptedException();
            }

            int index = --count;
            if (index == 0) {  // 到达屏障的计数器为0,则屏障被绊倒
                boolean ranAction = false;
                try {
                    final Runnable command = barrierCommand;
                    if (command != null)
                        command.run();
                    ranAction = true;
                    nextGeneration();//换代
                    return 0;
                } finally {//如果任务运行出错,则ranAction的值还是false,这里还是要breakBarrier
                    if (!ranAction)
                        breakBarrier();
                }
            }

            // 阻塞所有线程直到屏障被tripped, broken, interrupted, or timed out
            for (;;) {
                try {
                    if (!timed)
                        trip.await();//条件等待
                    else if (nanos > 0L)
                        nanos = trip.awaitNanos(nanos);
                } catch (InterruptedException ie) {
                    if (g == generation && ! g.broken) {
                        breakBarrier();
                        throw ie;
                    } else {
                        // We're about to finish waiting even if we had not
                        // been interrupted, so this interrupt is deemed to
                        // "belong" to subsequent execution.
                        Thread.currentThread().interrupt();
                    }
                }

                if (g.broken)
                    throw new BrokenBarrierException();

                if (g != generation)
                    return index;

                if (timed && nanos <= 0L) {
                    breakBarrier();
                    throw new TimeoutException();
                }
            }
        } finally {
            lock.unlock();
        }
    }

Exchanger

交换器。提供了两个线程之间能够交换对象的同步点。每条线程往这个交换器的exchange()方法传入一些对象,匹配伙伴线程,同时接收伙伴线程中的对象作为返回值。

示例

public static void main(String[] args) {
        ExecutorService service = Executors.newCachedThreadPool();
        final Exchanger exchanger = new Exchanger();
        service.execute(() -> {
            String threadASendData = "thread-AAA-SendData";
            System.out.println("线程" + Thread.currentThread().getName() + "发送数据" + threadASendData);
            try {
                String threadAReturnData = (String)exchanger.exchange(threadASendData);
                System.out.println("线程" + Thread.currentThread().getName() + "换回" + threadAReturnData);
            } catch (InterruptedException e1) {
            }
        });
        service.execute(() -> {
            String threadBSendData = "thread-BBB-SendData";
            System.out.println("线程" + Thread.currentThread().getName() + "发送数据" + threadBSendData);
            try {
                String threadBReturnData = (String)exchanger.exchange(threadBSendData);//交换数据
                System.out.println("线程" + Thread.currentThread().getName() + "换回" + threadBReturnData);
            } catch (InterruptedException e1) {
            }
        });
    }

源码剖析

我们从Exchanger内部静态Node类的属性开始分析。其中index,bound,collides是用于多槽位的,可以先不用考虑。

    @sun.misc.Contended static final class Node {
        int index;              // 竞争场所索引
        int bound;              // 上次记录的Exchanger.bound的值
        int collides;           // 当时绑定的CAS失败次数
        int hash;               // Pseudo-random for spins
        Object item;            // 当前线程要交换的值
        volatile Object match;  // 交换后的值
        volatile Thread parked; // 当阻塞时设置当前线程
    }

Node是每个线程自己用于数据交换的,内部的parked为要交换数据的线程。为了保证线程安全,Exchanger提供内部类Participant继承ThreadLocal,并初始化了Node。

接下来分析核心方法exchange()。exchange()方法有两个,下面是具有超时功能的方法。所谓超时,就是在指定时间内没有数据交换,就抛出异常超时异常,不会一直等待。

/** 
 * 等待其他线程到达交换点,然后与其进行数据交换。 
 * 如果其他线程到来,那么交换数据,返回。 
 * 如果其他线程未到来,那么当前线程等待,知道如下情况发生: 
 *   1.有其他线程来进行数据交换。 
 *   2.当前线程被中断。 
 *   3.超时。 
 */ 
public V exchange(V x, long timeout, TimeUnit unit)
        throws InterruptedException, TimeoutException {
        Object v;
        Object item = (x == null) ? NULL_ITEM : x;//当前线程要交换的值
        long ns = unit.toNanos(timeout);
        if ((arena != null ||
             (v = slotExchange(item, true, ns)) == null) &&//单槽位交换方法
            ((Thread.interrupted() ||
              (v = arenaExchange(item, true, ns)) == null)))//多槽位交换方法
            throw new InterruptedException();
        if (v == TIMED_OUT)
            throw new TimeoutException();//抛出超时异常
        return (v == NULL_ITEM) ? null : (V)v;
    }

下面分析单槽位交换方法。当arena为null是,会进入到该方法。否则表示存在多槽位,再判断当前线程是否中断,没中断就走多槽位交换方法。多槽位有点类似于ConcurrentHashMap的Node策略。这里有个问题需要强调下,因交换的场所是Slot(多槽位就是多个Slot),它进行了cache line的填充,避免了伪共享的问题。目前主流的缓存行是64字节,所以,1<<7位至少是一个缓存行的大小。

private final Object slotExchange(Object item, boolean timed, long ns) {
    // 这里会初始化participant的Node,注意participant是继承了ThreadLocal的
    Node p = participant.get();
    Thread t = Thread.currentThread();
    // 如果发生中断,返回null,会重设中断标志位,并没有直接抛异常
    if (t.isInterrupted()) // preserve interrupt status so caller can recheck
        return null;

    for (Node q;;) {
        // 当前exchanger槽位solt不为null,则说明有线程在等待
        if ((q = slot) != null) {
            // CAS重置槽位,用this对象的SLOT偏移量的值与q对比,相同则替换为null
            if (U.compareAndSwapObject(this, SLOT, q, null)) {
                //获取交换的数据
                Object v = q.item;
                //等待线程需要的数据
                q.match = item;
                //等待线程
                Thread w = q.parked;
                //唤醒等待的线程
                if (w != null)
                    U.unpark(w);
                return v; // 返回拿到的数据,交换完成
            }
            // create arena on contention, but continue until slot null
            //存在竞争,其它线程抢先了一步该线程,因此需要采用多槽位模式,这个后面再分析
            if (NCPU > 1 && bound == 0 &&//如果CPU内核大于1且竞争区域边界为0,则CAS
                U.compareAndSwapInt(this, BOUND, 0, SEQ))
                arena = new Node[(FULL + 2) << ASHIFT];//创建竞争区域
        }
        else if (arena != null) //多槽位不为空,需要执行多槽位交换
            return null; // caller must reroute to arenaExchange
        else { //还没有其他线程来占据槽位
            p.item = item;//设置当前Node的值
            // 设置槽位为p(也就是槽位被当前线程占据)
            if (U.compareAndSwapObject(this, SLOT, null, p))
                break; // 退出无限循环
            p.item = null; // 如果设置槽位失败,则有可能其他线程抢先了,重置item,重新循环
        }
    }

    //当前线程占据槽位,等待其它线程来交换数据
    int h = p.hash;
    long end = timed ? System.nanoTime() + ns : 0L;
    int spins = (NCPU > 1) ? SPINS : 1;
    Object v;
    // 直到成功交换到数据
    while ((v = p.match) == null) {
        if (spins > 0) {
            h ^= h << 1; h ^= h >>> 3; h ^= h << 10;
            if (h == 0)
                h = SPINS | (int)t.getId();
            else if (h < 0 && (--spins & ((SPINS >>> 1) - 1)) == 0)
                // 主动让出cpu,这样可以提供cpu利用率(反正当前线程也自旋等待,还不如让其它任务占用cpu)
                Thread.yield(); 
        }
        else if (slot != p) //其它线程来交换数据了,修改了solt,但是还没有设置match,再稍等一会
            spins = SPINS;
        //需要阻塞等待其它线程来交换数据
        //没发生中断,并且是单槽交换,没有设置超时或者超时时间未到 则继续执行
        else if (!t.isInterrupted() && arena == null &&
                 (!timed || (ns = end - System.nanoTime()) > 0L)) {
            // cas 设置BLOCKER,可以参考Thread 中的parkBlocker
            U.putObject(t, BLOCKER, this);
            // 需要挂起当前线程
            p.parked = t;
            if (slot == p)
                U.park(false, ns); // 阻塞当前线程
            // 被唤醒后    
            p.parked = null;
            // 清空 BLOCKER
            U.putObject(t, BLOCKER, null);
        }
        // 不满足前面 else if 条件,交换失败,需要重置solt
        else if (U.compareAndSwapObject(this, SLOT, p, null)) {
            v = timed && ns <= 0L && !t.isInterrupted() ? TIMED_OUT : null;
            break;
        }
    }
    //清空match
    U.putOrderedObject(p, MATCH, null);
    p.item = null;
    p.hash = h;
    // 返回交换得到的数据(失败则为null)
    return v;
}

https://www.iteye.com/blog/brokendreams-2253956

Phaser

Phaser是一个更加具有弹性的"同步屏障器"。可重用的同步barrier。

https://www.jianshu.com/p/f5132d9a0181

Semaphore 的内部工作流程也是基于 AQS,不同于 CyclicBarrier 和 ReentrantLock,不会使用到 AQS 的条件队列,都是在同步队列中操作,只是当前线程会被 park。

Semaphore 是 JUC 包提供的一个典型的共享锁,它通过自定义两种不同的同步器(FairSync 和 NonfairSync)提供了公平和非公平两种工作模式,两种模式下分别提供了限时/不限时、响应中断/不响应中断的获取资源的方法(限时获取总是及时响应中断的),而所有的释放资源的 release() 操作是统一的。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值