JUC多线程 (三)

一、J.U.C之并发工具类

1. CyclicBarrier

 1.1 介绍

  CyclicBarrier也叫同步屏障,在JDK1.5被引入的一个同步辅助类,在API中是这么介绍的:

允许一组线程全部等待彼此达到共同屏障点的同步辅助。 循环阻塞在涉及固定大小的线程方的程序中很有用,这些线程必须偶尔等待彼此。 屏障被称为循环,因为它可以在等待的线程被释放之后重新使用。

  CyclicBarrier好比一扇门,默认情况下关闭状态,堵住了线程执行的道路,直到所有线程都就位,门才打开,让所有线程一起通过。

  image

 1.2 实现分析

  6a8bcc76ceda51d930322f1ca00bc854_2018120817001

  通过上图我们可以看到CyclicBarrier的内部是使用重入锁ReentrantLock和Condition。它有两个构造方法:

  • CyclicBarrier(int parties):它将在给定数量的参与者(线程)处于等待状态时启动,但它不会在启动屏障时执行预定义的操作。parties表示拦截线程的数量。
  • CyclicBarrier(int parties, Runnable barrierAction) :创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,并在启动屏障时执行给定的屏障操作,该操作由最后一个进入屏障的线程执行。

  构造方法如下:

public CyclicBarrier(int parties, Runnable barrierAction) {
    if (parties <= 0) throw new IllegalArgumentException();
    this.parties = parties;
    this.count = parties;
    this.barrierCommand = barrierAction;
}
​
public CyclicBarrier(int parties) {
    this(parties, null);
}

  在CyclicBarrier中最重要的方法莫过于await()方法,每个线程调用await方法告诉CyclicBarrier已经到达屏障位置,线程被阻塞。源码如下:

public int await() throws InterruptedException, BrokenBarrierException {
    try {
        return dowait(false, 0L);
    } catch (TimeoutException toe) {
        throw new Error(toe); // cannot happen
    }
}

  await()方法的逻辑:如果该线程不是到达的最后一个线程,则他会一直处于等待状态,除非发生以下情况:

  1. 最后一个线程到达,即index == 0
  2. 超出了指定时间(超时等待)
  3. 其他的某个线程中断当前线程
  4. 其他的某个线程中断另一个等待的线程
  5. 其他的某个线程在等待屏障超时
  6. 其他的某个线程在此屏障调用reset()方法。reset()方法用于将屏障重置为初始状态。

 1.3 案例

  田径比赛,所有运动员准备好了之后,大家一起跑,代码如下

public class Demo1CyclicBarrier {
​
    public static void main(String[] args) {
        CyclicBarrier cyclicBarrier = new CyclicBarrier(5);
​
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(new Athlete(cyclicBarrier, "运动员" + i));
            threadList.add(t);
        }
​
        for (Thread t : threadList) {
            t.start();
        }
    }
​
    static class Athlete implements Runnable {
​
        private CyclicBarrier cyclicBarrier;
        private String name;
        public Athlete(CyclicBarrier cyclicBarrier, String name) {
            this.cyclicBarrier = cyclicBarrier;
            this.name = name;
        }
​
        @Override
        public void run() {
            System.out.println(name + "就位");
            try {
                cyclicBarrier.await();
                System.out.println(name + "跑到终点。");
            } catch (Exception e) {
            }
        }
    }
}

  执行结果:

   image  image

2. CountDownLatch

 2.1 介绍

  CountDownLatch是一个计数的闭锁,作用与CyclicBarrier有点儿相似。

  在API中是这样描述的:

用给定的计数初始化 CountDownLatch。由于调用了 countDown() 方法,所以在当前计数到达零之前,await 方法会一直受阻塞。之后,会释放所有等待的线程,await 的所有后续调用都将立即返回。

这种现象只出现一次——计数无法被重置。如果需要重置计数,请考虑使用 CyclicBarrier。

  • CountDownLatch:一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;
  • CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行。

  对于CountDownLatch来说,重点是“一个线程(多个线程)等待”,而其他的N个线程在完成“某件事情”之后,可以终止,也可以等待。而对于CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。

  CountDownLatch是通过一个计数器来实现的,计数器的初始值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就会减1。当计数器值到达0时,它表示所有的线程已经完成了任务,然后就可以恢复等待的线程继续执行了。如下图

  77726954a5d6134e5af6513294b70635_f65cc83b7b4664916fad5d1398a36005

 2.2 实现分析

  CountDownLatch结构如下

  98ef568ac27dba13d24cd2bb61eec8fa_2018120818002

  通过上面的结构图我们可以看到,CountDownLatch内部依赖Sync实现,而Sync继承AQS。CountDownLatch仅提供了一个构造方法:

  CountDownLatch(int count) : 构造一个用给定计数初始化的 CountDownLatch

public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

  sync为CountDownLatch的一个内部类,通过这个内部类Sync可以知道CountDownLatch是采用共享锁来实现的。最常用的两个方法是await()和countDown():

  • CountDownLatch提供await()方法来使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。内部使用AQS的getState方法获取计数器,如果计数器值不等于0,则会以自旋方式会尝试一直去获取同步状态。
  • CountDownLatch提供countDown() 方法递减锁存器的计数,如果计数到达零,则释放所有等待的线程。内部调用AQS的releaseShared(int arg)方法来释放共享锁同步状态。

 2.3 案例

  在CyclicBarrier应用场景之上进行修改,添加接力运动员。

  起点运动员应该等其他起点运动员准备好才可以起跑(CyclicBarrier)。

  接力运动员不需要关心其他人,只需和自己有关的起点运动员到接力点即可开跑(CountDownLatch)。

public class Demo2CountDownLatch {
​
    public static void main(String[] args) {
        CyclicBarrier cyclicBarrier = new CyclicBarrier(5);
​
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            CountDownLatch countDownLatch = new CountDownLatch(1);
            //起点运动员
            Thread t1 = new Thread(new Athlete(cyclicBarrier, countDownLatch, "起点运动员" + i));
​
            //接力运动员
            Thread t2 = new Thread(new Athlete(countDownLatch, "接力运动员" + i));
​
            threadList.add(t1);
            threadList.add(t2);
        }
​
        for (Thread t : threadList) {
            t.start();
        }
    }static class Athlete implements Runnable {
​
        private CyclicBarrier cyclicBarrier;
        private String name;
​
        CountDownLatch countDownLatch;
​
        //起点运动员
        public Athlete(CyclicBarrier cyclicBarrier, CountDownLatch countDownLatch, String name) {
            this.cyclicBarrier = cyclicBarrier;
            this.countDownLatch = countDownLatch;
            this.name = name;
        }//接力运动员
        public Athlete(CountDownLatch countDownLatch, String name) {
            this.countDownLatch = countDownLatch;
            this.name = name;
        }
​
        @Override
        public void run() {
            //判断是否是起点运动员
            if (cyclicBarrier != null) {
​
                System.out.println(name + "就位");
                try {
                    cyclicBarrier.await();
                    System.out.println(name + "到达交接点。");
​
                    //已经到达交接点
                    countDownLatch.countDown();
                } catch (Exception e) {
                }
            } //判断是否是接力运动员
            if (cyclicBarrier == null) {
                System.out.println(name + "就位");
                try {
                    countDownLatch.await();
                    System.out.println(name + "到达终点。");
                } catch (Exception e) {
                }
            }
        }
    }
}

  查看执行结果:

   image

3. Semaphore

 3.1 介绍

  Semaphore是一个控制访问多个共享资源的计数器,和CountDownLatch一样,其本质上是一个“共享锁”。

  Semaphore维护了一个信号量许可集。线程可以获取信号量的许可;当信号量中有可用的许可时,线程能获取该许可;否则线程必须等待,直到有可用的许可为止。 线程可以释放它所持有的信号量许可,被释放的许可归还到许可集中,可以被其他线程再次获取。

  举个例子:

  假设停车场仅有5个停车位,一开始停车场没有车辆所有车位全部空着,然后先后到来三辆车,停车场车位够,安排进去停车,然后又来三辆,这个时候由于只剩两个停车位,所有只能停两辆,其余一辆必须在外面候着,直到停车场有空车位,当然以后每来一辆都需要在外面等着。当停车场有车开出去,里面有空位了,则安排一辆车进去(至于是哪辆车 要看选择的机制是公平还是非公平)。

  Semaphore常用于约束访问一些(物理或逻辑)资源的线程数量。

  当信号量初始化为 1 时,可以当作互斥锁使用,因为它只有两个状态:有一个许可能使用,或没有许可能使用。当以这种方式使用时,“锁”可以被其他线程控制和释放,而不是主线程控制释放。

 3.2 实现分析

  160c03f2fd7eefecd2b6782101573388_271435043122187

  从上图可以看出Semaphore内部包含公平锁(FairSync)和非公平锁(NonfairSync),继承内部类Sync,其中Sync继承AQS(再一次阐述AQS的重要性)。

  Semaphore提供了两个构造函数:

  1. Semaphore(int permits) :创建具有给定的许可数和非公平的 Semaphore。
  2. Semaphore(int permits, boolean fair) :创建具有给定的许可数和给定的公平设置的 Semaphore。

  实现如下:(Semaphore默认选择非公平锁)

public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}
​
public Semaphore(int permits, boolean fair) {
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

  信号量获取

  Semaphore提供了acquire()方法来获取一个许可。

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

  FairSync内部使用AQS以共享模式获取同步状态,核心源码:

//公平 判断该线程是否位于CLH队列的列头
protected int tryAcquireShared(int acquires) {
    for (;;) {
        //判断该线程是否位于CLH队列的列头
        if (hasQueuedPredecessors())
            return -1;
        //获取当前的信号量许可
        int available = getState();
​
        //设置“获得acquires个信号量许可之后,剩余的信号量许可数”
        int remaining = available - acquires;
​
        //CAS设置信号量
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}
//非公平 对于非公平而言,因为它不需要判断当前线程是否位于CLH同步队列列头。
protected int tryAcquireShared(int acquires) {
    return nonfairTryAcquireShared(acquires);
}
​
final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}

  信号量释放

  获取了许可,当用完之后就需要释放,Semaphore提供release()来释放许可。

public void release() {
    sync.releaseShared(1);
}

  内部调用AQS释放许可,核心代码:

protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        //信号量的许可数 = 当前信号许可数 + 待释放的信号许可数
        int next = current + releases;
        if (next < current) // overflow
            throw new Error("Maximum permit count exceeded");
        //设置可获取的信号许可数为next
        if (compareAndSetState(current, next))
            return true;
    }
}

 3.3 案例

  停车为示例:

public class Demo3Semaphore {
​
    public static void main(String[] args) {
        Parking parking = new Parking(3);
        for (int i = 0; i < 5; i++) {
            new Car(parking).start();
        }
    }
​
    static class Parking {
        //信号量
        private Semaphore semaphore;
​
        Parking(int count) {
            semaphore = new Semaphore(count);
        }public void park() {
            try {
                //获取信号量
                semaphore.acquire();
                long time = (long) (Math.random() * 10);
                System.out.println(Thread.currentThread().getName() + "进入停车场,停车" + time + "秒...");
                Thread.sleep(time);
                System.out.println(Thread.currentThread().getName() + "开出停车场...");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                //释放信号量
                semaphore.release();
            }
        }
    }
​
    static class Car extends Thread {
        Parking parking;
​
        Car(Parking parking) {
            this.parking = parking;
        }
​
        @Override
        public void run() {
            //进入停车场
            parking.park();
        }
    }
}

  执行结果:

   image  image

二、J.U.C之并发容器ConcurrentHashMap

1. 介绍

 HashMap是我们用得非常频繁的一个集合,但是它是线程不安全的。并且在多线程环境下,put操作是有可能产生死循环,不过在JDK1.8的版本中更换了数据插入的顺序,已经解决了这个问题。

 为了解决该问题,提供了Hashtable和Collections.synchronizedMap(hashMap)两种解决方案,但是这两种方案都是对读写加锁,独占式。一个线程在读时其他线程必须等待,吞吐量较低,性能较为低下。而J.U.C给我们提供了高性能的线程安全HashMap:ConcurrentHashMap。

 在1.8版本以前,ConcurrentHashMap采用分段锁的概念,使锁更加细化,但是1.8已经改变了这种思路,而是利用CAS+Synchronized来保证并发更新的安全,当然底层采用数组+链表+红黑树的存储结构。

2. JDK7 HashMap

 HashMap 是最简单的,它不支持并发操作,下面这张图是 HashMap 的结构:

  8ec2e176b8fb85555566bdd83cd73564_1

 HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。

 public HashMap(int initialCapacity, float loadFactor)初始化方法的参数说明:

capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。 loadFactor:负载因子,默认为 0.75。 threshold:扩容的阈值,等于 capacity * loadFactor

 put 过程

  • 数组初始化,在第一个元素插入 HashMap 的时候做一次数组的初始化,先确定初始的数组大小,并计算数组扩容的阈值。
  • 计算具体数组位置,使用key进行hash值计算,根据hash值计算应该放在哪个数组中。
  • 找到数组下标后,会先进行 key 判断是否重复,如果没有重复,就准备将新值放入到链表的表头(在多线程操作中,这种操作会造成死循环,在jdk1.8已解决)。
  • 数组扩容,在插入新值的时候,如果当前的 size 已经达到了阈值,并且要插入的数组位置上已经有元素,那么就会触发扩容,扩容后,数组大小为原来的 2 倍。扩容就是用一个新的大数组替换原来的小数组,并将原来数组中的值迁移到新的数组中。

 get过程

  • 根据 key 计算 hash 值。
  • 根据hash值找到相应的数组下标。
  • 遍历该数组位置处的链表,直到找到相等的 key。

3. JDK7 ConcurrentHashMap

 ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。

 整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多人都会将其描述为分段锁。简单的说,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的。

 70dc40c36b850a77bbda6d5b88c28c57_3

 再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,每次操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的。

 初始化

 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel)初始化方法

  • initialCapacity:整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment。
  • concurrencyLevel:并发数(或者Segment 数,有很多叫法,重要的是如何理解)。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。
  • loadFactor:负载因子,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。

 举个简单的例子:

 用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:

  • Segment 数组长度为 16,不可以扩容
  • Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容
  • 这里初始化了 segment[0],其他位置还是 null,

 put过程

  • 根据 hash 值能找到相应的 Segment,之后就是 Segment 内部的 put 操作了。
  • Segment 内部是由 数组+链表 组成的,由于有独占锁的保护,所以 segment 内部的操作并不复杂。保证多线程安全的,就是做了一件事,那就是获取该 segment 的独占锁。
  • Segment 数组不能扩容,rehash方法扩容是 segment 数组某个位置内部的数组 HashEntry[] 进行扩容,扩容后,容量为原来的 2 倍。

 get过程

  • 计算 hash 值,找到 segment 数组中的具体位置
  • segment 数组中也是数组,再根据 hash 找到数组中具体值的位置
  • 到这里是链表了,顺着链表进行查找即可

4. JDK8 HashMap

 Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成

 根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度。

 为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度。

  009f9a7435bb2c4d54cb20723f3829ec_2

 jdk7 中使用 Entry 来代表每个 HashMap 中的数据节点,jdk8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。

 我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的。

 put过程

 和jdk7的put差不多

  • 和 Jdk7 不一样的地方就是,jdk7是先扩容后插入新值的,jdk8 先插入值再扩容
  • 先使用链表进行存放数据,当数量超过8个的时候,将链表转为红黑树

 get 过程分析

  1. 计算 key 的 hash 值,根据 hash 值找到对应数组下标。
  2. 判断数组该位置处的元素是否刚好就是我们要找的,如果不是,走第三步。
  3. 判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步。
  4. 遍历链表,直到找到相等(==或equals)的 key。

5. JDK8 ConcurrentHashMap

 Java7 中实现的 ConcurrentHashMap 还是比较复杂的,Java8 对 ConcurrentHashMap 进行了比较大的改动。可以参考 Java8 中 HashMap 相对于 Java7 HashMap 的改动,对于 ConcurrentHashMap,Java8 也引入了红黑树。

 在1.8版本以前,ConcurrentHashMap采用分段锁的概念,使锁更加细化,但是1.8已经改变了这种思路,而是利用CAS+Synchronized来保证并发更新的安全底层采用数组+链表+红黑树的存储结构

 c39d7a6d5f43de7718d70ec6c56b387c_4

6. 使用场景

 ConcurrentHashMap通常只被看做并发效率更高的Map,用来替换其他线程安全的Map容器,比如Hashtable和Collections.synchronizedMap。线程安全的容器,特别是Map,很多情况下一个业务中涉及容器的操作有多个,即复合操作,而在并发执行时,线程安全的容器只能保证自身的数据不被破坏,和数据在多个线程间是可见的,但无法保证业务的行为是否正确。

 ConcurrentHashMap总结:

  • HashMap是线程不安全的,ConcurrentHashMap是线程安全的,但是线程安全仅仅指的是对容器操作的时候是线程安全的
  • ConcurrentHashMap的public V get(Object key)不涉及到锁,也就是说获得对象时没有使用锁
  • put、remove方法,在jdk7使用锁,但多线程中并不一定有锁争用,原因在于ConcurrentHashMap将缓存的变量分到多个Segment,每个Segment上有一个锁,只要多个线程访问的不是一个Segment就没有锁争用,就没有堵塞,各线程用各自的锁,ConcurrentHashMap缺省情况下生成16个Segment,也就是允许16个线程并发的更新而尽量没有锁争用。而在jdk8中使用的CAS+Synchronized来保证线程安全,比加锁的性能更高
  • ConcurrentHashMap线程安全的,允许一边更新、一边遍历,也就是说在对象遍历的时候,也可以进行remove、put操作,且遍历的数据会随着remove、put操作产出变化,

 案例1:遍历的同时删除

public class Demo4ConcurrentHashMap1 {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap();
        //Map<String, Integer> map = new ConcurrentHashMap<>();
        //Map<String, Integer> map = new Hashtable<>();
​
        map.put("a", 1);
        map.put("b", 1);
        map.put("c", 1);
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            map.remove(entry.getKey());
        }
​
        System.out.println(map.size());
    }
}

  执行结果:HashMap和Hashtable第二次遍历时报错java.util.ConcurrentModificationException,ConcurrentHashMap输出结果:0

 案例2:业务操作的线程安全不能保证

public class Demo4ConcurrentHashMap2 {
    public static void main(String[] args) {
        final Map<String, Integer> count = new HashMap<>();
        //final Map<String, Integer> count = new ConcurrentHashMap<>();
        //final Hashtable<String, Integer> count = new Hashtable<>();
        count.put("count", 0);
​
        Runnable task = new Runnable() {
            @Override
            public void run() {
                //synchronized (count) {
                    int value;
                    for (int i = 0; i < 2000; i++) {
                        value = count.get("count");
                        count.put("count", value + 1);
                    //}
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
​
        try {
            Thread.sleep(1000l);
            System.out.println(count);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

  执行结果:HashMap输出2477,ConcurrentHashMap输出2904,Hashtable输出2959

 案例3:多线程删除

public class Demo4ConcurrentHashMap3 {
    public static void main(String[] args) {
        final Map<String, Integer> count = new HashMap<>();
        //final Map<String, Integer> count = new ConcurrentHashMap<>();
        //final Hashtable<String, Integer> count = new Hashtable<>();
​
        for (int i = 0; i < 2000; i++) {
            count.put("count" + i, 1);
        }
​
        Runnable task1 = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 500; i++) {
                    count.remove("count" + i);
                }
            }
        };
        Runnable task2 = new Runnable() {
            @Override
            public void run() {
                for (int i = 1000; i < 1500; i++) {
                    count.remove("count" + i);
                }
            }
        };
​
        new Thread(task1).start();
        new Thread(task2).start();
​
        try {
            Thread.sleep(1000l);
            System.out.println(count.size());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

  执行结果:HashMap输出21031,ConcurrentHashMap输出1000,Hashtable输出1000

7. 对比Hashtable

 Hashtable和ConcurrentHashMap的不同点:

  • Hashtable对get、put、remove都使用了同步操作,它的同步级别是正对Hashtable来进行同步的,也就是说如果有线程正在遍历集合,其他的线程就暂时不能使用该集合了,这样无疑就很容易对性能和吞吐量造成影响,从而形成单点。而ConcurrentHashMap则不同,它只对put,remove操作使用了同步操作,get操作并不影响。
  • Hashtable在遍历的时候,如果其他线程,包括本线程对Hashtable进行了put,remove等更新操作的话,就会抛出ConcurrentModificationException异常,但如果使用ConcurrentHashMap的话,就不用考虑这方面的问题了

8. 了解ConcurrentSkipListMap

 通过对前面ConcurrentHashMap的学习,我们了解到Map存放数据的两种数据结构:链表和红黑树,这两种数据结构各自都有着优缺点。而ConcurrentSkipListMap使用的是第三种数据结构:SkipList。SkipList有着不低于红黑树的效率。

 Skip List ,称之为跳表,它是一种可以替代平衡树的数据结构,其数据元素默认按照key值升序,天然有序。Skip list让已排序的数据分布在多层链表中,以0-1随机数决定一个数据的向上攀升与否,通过“空间来换取时间”的一个算法,在每个节点中增加了向前的指针,在插入、删除、查找时可以忽略一些不可能涉及到的结点,从而提高了效率。

 我们先看一个简单的链表,如下:

72f9c124984577ab2df4664d561c3f6f_2018120824001

 如果我们需要查询9、21、30,则需要比较次数为3 + 6 + 8 = 17 次,那么有没有优化方案呢?有!我们将该链表中的某些元素提炼出来作为一个比较“索引”,如下:

5e2191a04fb13c6290dd2a5dc0467c24_2018120824002

 我们先与这些索引进行比较来决定下一个元素是往右还是下走,由于存在“索引”的缘故,导致在检索的时候会大大减少比较的次数。当然元素不是很多,很难体现出优势,当元素足够多的时候,这种索引结构就会大显身手。

 我们将上图再做一些扩展就可以变成一个典型的SkipList结构:

  1c26cd424e845a921c5eef1efafdf108_1559181479264

 SkipListd的查找

 对于上面我们我们要查找元素21,其过程如下:

  1. 比较3,大于,往后找(9),
  2. 比9大,继续往后找(25),但是比25小,则从9的下一层开始找(16)
  3. 16的后面节点依然为25,则继续从16的下一层找
  4. 找到21

  c5ed1a6c2b71b60e47f738e2bdd73885_1559181804985

  绿线代表查询路径

 SkipList的插入

 SkipList的插入操作主要包括:

  1. 查找合适的位置。这里需要明确一点就是在确认新节点要占据的层次K时,采用丢硬币的方式,完全随机。如果占据的层次K大于链表的层次,则重新申请新的层,否则插入指定层次
  2. 申请新的节点
  3. 调整指针

 假定我们要插入的元素为23,经过查找可以确认她是位于25后,9、16、21前。当然需要考虑申请的层次K。

 如果层次K > 3,需要申请新层次(Level 4)

  5bd7f53ca061b7aebb79b48f16252c90_1559182212446

 如果层次 K = 2,直接在Level 2 层插入即可

  4dd635ba70f43cda7066d5a1e8d068ea_1559182289418

 SkipList的删除

 删除节点和插入节点思路基本一致:找到节点,删除节点,调整指针。

 比如删除节点9,如下:

  99a4814f4b01e0f7747df0bd0b69d2e2_1559182495579

三、J.U.C队列

要实现一个线程安全的队列有两种方式:阻塞和非阻塞

 image

1. 非阻塞队列ConcurrentLinkedQueue

 在单线程编程中我们会经常用到一些集合类,比如ArrayList、HashMap等,但是这些类都不是线程安全的类。在面试中也经常会有一些考点,比如ArrayList不是线程安全的,Vector是线程安全。而保障Vector线程安全的方式,是非常粗暴的在方法上用synchronized独占锁,将多线程执行变成串行化。要想将ArrayList变成线程安全的也可以使用Collections.synchronizedList(List<T> list)方法ArrayList转换成线程安全的,但这种转换方式依然是通过synchronized修饰方法实现的,很显然这不是一种高效的方式,同时,队列也是我们常用的一种数据结构。

 为了解决线程安全的问题,J.U.C为我们准备了ConcurrentLinkedQueue这个线程安全的队列。从类名就可以看的出来实现队列的数据结构是链式。ConcurrentLinkedQueue是一个基于链接节点的无边界的线程安全队列,遵循队列的FIFO原则,队尾入队,队首出队。采用CAS算法来实现的。

 使用案例:

public class ConcurrentLinkedQueueDemo {
    public static void main(String[] args) throws Exception {
        Queue<String> queue = new ConcurrentLinkedQueue<String>();
        for (int i = 0; i < 10000; i++) {
            //队列中添加元素
            queue.add(String.valueOf(i));
        }
​
        QueueDemo1 demo1 = new QueueDemo1(queue);
​
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(demo1);
            t.start();
        }
    }
}
class QueueDemo1 implements Runnable {
    Queue<String> queue;
​
    public QueueDemo1(Queue<String> queue) {
        this.queue = queue;
    }
​
    public void run() {
        try {
            long start = new Date().getTime();
            //检索并移除此队列的头,如果此队列为空,则返回 null
            while (queue.poll() != null) {
                //if (queue.size() == 0) {
                //}
​
                if (queue.isEmpty()) {
                }
            }
            System.out.println(System.currentTimeMillis() - start);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

  执行结果:

   image

  注意:

  1. ConcurrentLinkedQueue的.size() 是要遍历一遍集合的,很慢的,所以尽量要避免用size
  2. 使用了这个ConcurrentLinkedQueue 类之后还是需要自己进行同步或加锁操作。例如queue.isEmpty()后再进行队列操作queue.add()是不能保证安全的,因为可能queue.isEmpty()执行完成后,别的线程开始操作队列。

2. 阻塞队列BlockingQueue

 2.1 BlockingQueue介绍

  BlockingQueue即阻塞队列,从阻塞这个词可以看出,在某些情况下对阻塞队列的访问可能会造成阻塞。被阻塞的情况主要有如下两种:

  1. 当队列满了的时候进行入队列操作
  2. 当队列空了的时候进行出队列操作

  因此,当一个线程试图对一个已经满了的队列进行入队列操作时,它将会被阻塞,除非有另一个线程做了出队列操作;同样,当一个线程试图对一个空队列进行出队列操作时,它将会被阻塞,除非有另一个线程进行了入队列操作。

   d39dbe0d1573a6a5b1aba53e4eae337a_20150929153140497

  BlockingQueue 对插入操作、移除操作、获取元素操作提供了四种不同的方法用于不同的场景中使用:

  1. 抛出异常
  2. 返回特殊值(null 或 true/false,取决于具体的操作)
  3. 阻塞等待此操作,直到这个操作成功
  4. 阻塞等待此操作,直到成功或者超时指定时间。

  总结如下:

   image

  接下来我们介绍这个接口的几个实现类。 

 2.2 ArrayBlockingQueue

  ArrayBlockingQueue是一个由数组实现的有界阻塞队列。该队列采用FIFO的原则对元素进行排序添加的。

  ArrayBlockingQueue为有界且固定,其大小在构造时由构造函数来决定,确认之后就不能再改变了。

  ArrayBlockingQueue支持对等待的生产者线程和使用者线程进行排序的可选公平策略,但是在默认情况下不保证线程公平的访问,在构造时可以选择公平策略(fair = true)。公平性通常会降低吞吐量,但是减少了可变性和避免了“不平衡性”。

  ArrayBlockingQueue继承AbstractQueue,实现BlockingQueue接口。java.util.AbstractQueue,在Queue接口中扮演着非常重要的作用,该类提供了对queue操作的骨干实现。BlockingQueue继承java.util.Queue为阻塞队列的核心接口,提供了在多线程环境下的出列、入列操作,作为使用者,则不需要关心队列在什么时候阻塞线程,什么时候唤醒线程,所有一切均由BlockingQueue来完成。

  ArrayBlockingQueue内部使用可重入锁ReentrantLock + Condition来完成多线程环境的并发操作。

  • items,一个定长数组,维护ArrayBlockingQueue的元素
  • takeIndex,int,为ArrayBlockingQueue队首位置
  • putIndex,int,ArrayBlockingQueue队尾位置
  • count,元素个数
  • lock,锁,ArrayBlockingQueue出列入列都必须获取该锁,两个步骤公用一个锁
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, Serializable {
    private static final long serialVersionUID = -817911632652898426L;
    final Object[] items;
    int takeIndex;
    int putIndex;
    int count;
    // 重入锁
    final ReentrantLock lock;
    // notEmpty condition
    private final Condition notEmpty;
    // notFull condition
    private final Condition notFull;
    transient ArrayBlockingQueue.Itrs itrs;
}

  使用示例:

public class Demo6BlockingQueueTest {
    //最大容量为5的数组阻塞队列
    private static ArrayBlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(5, true);
    //private static LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>(5);
​
    public static void main(String[] args) {
​
        Thread t1 = new Thread(new ProducerTask());
        Thread t2 = new Thread(new ConsumerTask());
​
        //启动线程  
        t1.start();
        t2.start();
​
    }
​
    //生产者
    static class ProducerTask implements Runnable {
        private Random rnd = new Random();
​
        @Override
        public void run() {
            try {
                while (true) {
                    int value = rnd.nextInt(100);
                    //如果queue容量已满,则当前线程会堵塞,直到有空间再继续
                    queue.put(value);
​
                    System.out.println("生产者:" + value);
​
                    TimeUnit.MILLISECONDS.sleep(100); //线程休眠
                }
            } catch (Exception e) {
            }
        }
    }
​
    //消费者
    static class ConsumerTask implements Runnable {
        @Override
        public void run() {
            try {
                while (true) {
                    //如果queue为空,则当前线程会堵塞,直到有新数据加入
                    Integer value = queue.take();
​
                    System.out.println("消费者:" + value);
​
                    TimeUnit.MILLISECONDS.sleep(15); //线程休眠
                }
            } catch (Exception e) {
            }
        }
    }
}

  执行结果:生产者生产数据入列后进入休眠,消费者立马进行出列消费数据,然后休眠15毫秒queue为空继续等待,直到生产者休眠到100毫秒后继续生产然后消费数据;LinkedBlockingQueue的效果跟ArrayBlockingQueue是一样的。

 2.3 LinkedBlockingQueue

  LinkedBlockingQueue和ArrayBlockingQueue的使用方式基本一样,但还是有一定的区别:

   1)队列的数据结构不同

    ArrayBlockingQueue是一个由数组支持的有界阻塞队列

    LinkedBlockingQueue是一个基于链表的有界(可设置)阻塞队列

   2)队列中锁的实现不同

    ArrayBlockingQueue实现的队列中的锁是没有分离的,即生产和消费用的是同一个锁;

    LinkedBlockingQueue实现的队列中的锁是分离的,即生产用的是putLock,消费是takeLock

   3)在生产或消费时操作不同

    ArrayBlockingQueue实现的队列中在生产和消费的时候,是直接将枚举对象插入或移除的;

    LinkedBlockingQueue实现的队列中在生产和消费的时候,需要把枚举对象转换为Node进行插入或移除,会影响性能

   4)队列大小初始化方式不同

    ArrayBlockingQueue实现的队列中必须指定队列的大小;

    LinkedBlockingQueue实现的队列中可以不指定队列的大小,但是默认是Integer.MAX_VALUE

 2.4 PriorityBlockingQueue

  PriorityBlockingQueue类似于ArrayBlockingQueue内部使用一个独占锁来控制,同时只有一个线程可以进行入队和出队

  PriorityBlockingQueue是一个优先级队列,它在java.util.PriorityQueue的基础上提供了可阻塞的读取操作。它是无界的,就是说向Queue里面增加元素没有数量限制,但可能会导致内存溢出而失败。

  PriorityBlockingQueue始终保证出队的元素是优先级最高的元素,并且可以定制优先级的规则,内部使用二叉堆,通过使用一个二叉树最小堆算法来维护内部数组,这个数组是可扩容的,当当前元素个数>=最大容量时候会通过算法扩容。值得注意的是为了避免在扩容操作时候其他线程不能进行出队操作,实现上使用了先释放锁,然后通过CAS保证同时只有一个线程可以扩容成功。

  小结:

   1、优先队列不允许空值,而且不支持non-comparable(不可比较)的对象,比如用户自定义的类。优先队列要求使用Java Comparable和Comparator接口给对象排序,并且在排序时会按照优先级处理其中的元素。

   2、优先队列的头是基于自然排序或者Comparator排序的最小元素。如果有多个对象拥有同样的排序,那么就可能随机地取其中任意一个。也可以通过提供的Comparator(比较器)在队列实现自定的排序。当我们获取队列时,返回队列的头对象。

   3、优先队列的大小是不受限制的,但在创建时可以指定初始大小,当我们向优先队列增加元素的时候,队列大小会自动增加。

   4、PriorityQueue是非线程安全的,所以Java提供了PriorityBlockingQueue(实现BlockingQueue接口)用于Java多线程环境。

  使用案例:

public class Demo7PriorityBlockQueue {
​
    public static void main(String[] args) throws InterruptedException {
        PriorityBlockingQueue<User> queue = new PriorityBlockingQueue<User>();
​
        PriorityDemo demo = new PriorityDemo(queue);
​
        for (int i = 0; i < 5; i++) {
            new Thread(demo).start();
        }
​
        Thread.sleep(100);
​
        User u = queue.poll();
        while (u != null) {
            System.out.println("优先级是:" + u.getPriority() + "," + u.getUsername());
            u = queue.poll();
        }
    }
​
    static class PriorityDemo implements Runnable {
​
        PriorityBlockingQueue queue;
        Random r = new Random();
​
        public PriorityDemo(PriorityBlockingQueue queue) {
            this.queue = queue;
        }
​
        @Override
        public void run() {
            for (int i = 0; i < 3; i++) {
                User user = new User();
                user.setPriority(r.nextInt(100));
                user.setUsername("张三" + i);
​
                queue.add(user);
            }
        }
    }
​
    static class User implements Comparable<User> {
​
        private Integer priority;
        private String username;
​
        @Override
        public int compareTo(User user) {
            //System.out.println("比较结果"+this.priority.compareTo(user.getPriority()));
            return this.priority.compareTo(user.getPriority());
        }
​
        public Integer getPriority() {
            return priority;
        }
​
        public void setPriority(Integer priority) {
            this.priority = priority;
        }
​
        public String getUsername() {
            return username;
        }
​
        public void setUsername(String username) {
            this.username = username;
        }
    }
}

  执行结果:

   image

 2.5 SynchronousQueue

  SynchronousQueue,实际上它不是一个真正的队列,因为它不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。

  仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。这种实现队列的方式看似很奇怪,但由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟。

  直接交付方式还会将更多关于任务状态的信息反馈给生产者。当交付被接受时,它就知道消费者已经得到了任务,而不是简单地把任务放入一个队列——这种区别就好比将文件直接交给同事,还是将文件放到她的邮箱中并希望她能尽快拿到文件。

  SynchronousQueue对于正在等待的生产者和使用者线程而言,默认是非公平排序,也可以选择公平排序策略。但是,使用公平所构造的队列可保证线程以 FIFO 的顺序进行访问。 公平通常会降低吞吐量,但是可以减小可变性并避免得不到服务。

  SynchronousQueue特点:

  • 是一种阻塞队列,其中每个 put 必须等待一个 take,反之亦然。同步队列没有任何内部容量,甚至连一个队列的容量都没有。

  • 是线程安全的,是阻塞的。

  • 不允许使用 null 元素。

  • 公平排序策略是指调用put的线程之间,或take的线程之间的线程以 FIFO 的顺序进行访问。

  • SynchronousQueue的方法:

    • iterator(): 永远返回空,因为里面没东西。
    • peek() :永远返回null。
    • put() :往queue放进去一个element以后就一直wait直到有其他thread进来把这个element取走。
    • offer() :往queue里放一个element后立即返回,如果碰巧这个element被另一个thread取走了,offer方法返回true,认为offer成功;否则返回false。
    • offer(2000, TimeUnit.SECONDS) :往queue里放一个element但等待时间后才返回,和offer()方法一样。
    • take() :取出并且remove掉queue里的element,取不到东西他会一直等。
    • poll() :取出并且remove掉queue里的element,方法立即能取到东西返回。否则立即返回null。
    • poll(2000, TimeUnit.SECONDS) :等待时间后再取,并且remove掉queue里的element。
    • isEmpty():永远是true。
    • remainingCapacity() :永远是0。
    • remove()和removeAll() :永远是false。

  使用案例:

public class Demo8SynchronousQueue {
    public static void main(String[] args) throws InterruptedException {
        SynchronousQueue<Integer> queue = new SynchronousQueue<Integer>();
​
        new Thread(new Product(queue)).start();
        new Thread(new Customer(queue)).start();
    }
​
    static class Product implements Runnable {
        SynchronousQueue<Integer> queue;
        Random r = new Random();
​
        public Product(SynchronousQueue<Integer> queue) {
            this.queue = queue;
        }
​
        @Override
        public void run() {
            while (true) {
                int number = r.nextInt(1000);
                System.out.println("等待5秒后运送" + number);
                try {
                    TimeUnit.SECONDS.sleep(5);
​
                    queue.put(number);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
​
    static class Customer implements Runnable {
        SynchronousQueue<Integer> queue;
​
        public Customer(SynchronousQueue<Integer> queue) {
            this.queue = queue;
        }
​
        @Override
        public void run() {
            while (true) {
                try {
                    System.out.println("收到了:" + queue.take());
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

  执行结果:

   image

 

posted on 2025-12-05 05:17  花溪月影  阅读(15)  评论(0)    收藏  举报