Java并发编程04:并发容器

并发容器的引出: 售票问题

一个容器存储了一些票,要求多个线程并发从容器中取票且不出错.

实现1:使用List-非原子性操作

直接使用List存储票会发生重售和超售,因为List的所有操作都是非原子性的.

public class TicketSeller1 {

    static List<String> tickets = new ArrayList<>();

    static {
        for (int i = 0; i < 1000; i++) {
            tickets.add("票-" + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (tickets.size() > 0) {
                    // List的remove操作不是原子性的
                    System.out.println("销售了:" + tickets.remove(0));
                }
            }).start();
        }
    }
}

输出如下,我们发现发生了重售:

...
销售了:票-998
销售了:票-999
销售了:票-999

实现2:使用Vector-判断与操作分离,复合操作不保证原子性

Vector的所有操作均为原子性的,但仍会出现问题,因为判断与操作是分离的,形成的复合操作不能保证原子性.

public class TicketSeller2 {

    static Vector<String> tickets = new Vector<>();

    static {
        for (int i = 0; i < 1000; i++) {
            tickets.add("票-" + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (tickets.size() > 0) {
                    // 线程判断后睡10毫秒再执行取操作,放大复合操作的问题
                    try {
                        TimeUnit.MILLISECONDS.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("销售了:" + tickets.remove(0));
                }
            }).start();
        }
    }
}

实现3: 使用同步代码块锁住复合操作-保证正确性但效率低

我们使用synchronized代码块将判断和取票操作锁在一起执行,保证其原子性.
这样可以保证售票过程的正确性,但每次取票都要锁定整个队列,效率低.

public class TicketSeller3 {

    static List<String> tickets = new ArrayList<>();

    static {
        for (int i = 0; i < 1000; i++) {
            tickets.add("票-" + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (tickets.size() > 0) {
                    // sychronized 保证了原子性
                    synchronized (tickets) {
                        System.out.println("销售了:" + tickets.remove(0));
                    }
                }
            }).start();
        }
    }
}

实现4: 使用并发队列,先取票再判断

使用JDK1.5之后提供的并发队列ConcurrentLinkedQueue存储元素,其底层使用CAS实现而非加锁实现的,其效率较高.
并发队列ConcurrentLinkedQueuepoll()方法会尝试从队列头中取出一个元素,若获取不到,则返回null,对其返回值做判断可以实现先取票后判断,可以避免加锁.

public class TicketSeller4 {

    static ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

    static {
        for (int i = 0; i < 1000; i++) {
            queue.add("票-" + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (true) {
                    // 尝试取出队列头,若取不到则返回null
                    String t = queue.poll(); 
                    if (t == null) {
                        break;
                    }
                    System.out.println("销售了:" + t);
                }
            }).start();
        }
    }
}

并发容器

Map/Set

MapSet容器类型是类似的,Set无非就是屏蔽了Mapvalue项,只保留key项.

在这里插入图片描述

非并发容器

主要的非并发容器有HashMap,TreeMap,LinkedHashMap

并发容器

主要的并发容器有HashTable,SynchronizedMap,ConcurrentMap.

  • HashTableSynchronizedMap的效率较低,其同步的实现原理类似,都是给容器的所有方法都加锁.

    其中SynchronizedMap使用装饰器模式,调用其构造方法并传入一个Map实现类,返回一个同步的Map容器.

  • ConcurrentMap的效率较高,有两个实现类:

    • ConcurrentHashMap: 使用哈希表实现,key是无序的
    • ConcurrentSkipListMap: 使用跳表实现,key是有序的

    其同步的实现原理在JDK1.8前后不同

    • 在JDK1.8以前,其实现同步使用的是分段锁,将整个容器分为16段(Segment),每次操作只锁住操作的那一段,是一种细粒度更高的锁.
    • 在JDK1.8及以后,其实现同步用的是Node+CAS.关于CAS的实现,可以看这篇文章:乐观锁CAS
public static void main(String[] args) {

    Map<String, String> map = Collections.synchronizedMap(new HashMap<>());	// 所有同步方法都锁住整个容器
    // Map<String, String> map = new Hashtable<>();							// 所有同步方法都锁住整个容器
    // Map<String, String> map = new ConcurrentHashMap<>(); 				// 1.8以前使用分段锁,1.8以后使用CAS
    // Map<String, String> map = new ConcurrentSkipListMap<>(); 			// 使用跳表,并发且有序

    Random r = new Random();
    Thread[] ths = new Thread[100];
    CountDownLatch latch = new CountDownLatch(ths.length); // 启动了一个门闩,每有一个线程退出,门闩就减1,直到所有线程结束,门闩打开,主线程结束

    long start = System.currentTimeMillis();
    // 创建100个线程,每个线程添加10000个元素到map,并启动这些线程
    for (int i = 0; i < ths.length; i++) {
        ths[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                map.put("a" + r.nextInt(10000), "a" + r.nextInt(100000));
            }
            latch.countDown();
        }, "t" + i);
    }
    Arrays.asList(ths).forEach(Thread::start);

    try {
        latch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    long end = System.currentTimeMillis();
    System.out.println(end - start);	
    System.out.println(map.size());
}

队列

在这里插入图片描述

低并发队列

低并发队列有:VectorSynchronizedList,其中vector类似HashTable,是JDK1.2就提供的类;SynchronizedList类似SynchronizedMap使用装饰器模式,其构造函数接受一个List实现类并返回同步List,在java.util.Collections包下.
它们实现同步的原理都是将所有方法用同步代码块包裹起来.

写时复制CopyOnWriteList

CopyOnWriteArrayList位于java.util.concurrent包下,它实现同步的方式是: 当发生写操作(添加,删除,修改)时,就会复制原有容器然后对新复制出的容器进行写操作,操作完成后将引用指向新的容器.其写效率非常低,读效率非常高

  • 优点: 读写分离,使得读操作不需要加锁,效率极高
  • 缺点: 写操作效率极低
  • 应用场合: 应用在读少写多的情况,如事件监听器

高并发队列

高并发队列的方法
方法抛出异常返回特殊值一直阻塞
(非阻塞队列不可用)
阻塞一段时间
(非阻塞队列不可用)
插入元素add(element)offer(element)put(element)offer(element,time,unit)
移除首个元素remove()poll()take()poll(time,unit)
返回首个元素element()peek()不可用不可用

对于高并发队列,若使用不同的方法对空队列执行查询和删除,以及对满队列执行插入,会产生不同行为

  • 抛出异常: 使用add(),remove(),element()方法,若执行错误操作会直接抛出异常
  • 返回特殊值: 若使用offer(),poll(),peek()方法执行错误操作会返回falsenull,并放弃当前错误操作,不抛出异常.
  • 一直阻塞: 若使用put(),take()方法执行错误操作,当前线程会一直阻塞直到条件允许才唤醒线程执行操作.
  • 阻塞一段时间: 若使用offer(),poll()方法并传入时间单位,会将当前方法阻塞一段时间,若阻塞时间结束后仍不满足条件则返回falsenull,并放弃当前错误操作,不抛出异常.
非阻塞队列ConcurrentLinkedQueue

非阻塞队列使用CAS保证操作的原子性,不会因为加锁而阻塞线程.类似于ConcurrentMap

阻塞队列BlockingQueue

阻塞队列的常用实现类有LinkedBlockingQueue,ArrayBlockingQueue,DelayedQueue,TransferQueue,SynchronousQueue. 分别对应于不同的应用场景.

经典阻塞队列LinkedBlockingQueueArrayBlockingQueue

LinkedBlockingQueueArrayBlockingQueue是阻塞队列的最常用实现类,用来更容易地实现生产者/消费者模式.

public static void main(String[] args) {

    // 阻塞队列,设置其最大容量为10
    BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);

    // 启动生产者线程
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            for (int j = 0; j < 100; j++) {
                try {
                    // 使用put()方法向队列插入元素,队列程满了则当前线程阻塞
                    queue.put("product" + j);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "producer" + i).start();
    }

    // 启用消费者线程
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            while (true) {
                try {
                    // 使用take()方法从队列取出元素,队列空了则当前线程阻塞
                    queue.take();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "consumer" + i).start();
    }
}
延迟队列DelayedQueue

延迟队列DelayedQueue中存储的元素必须实现Delay接口,其中定义了getDelay()方法;而Delay接口继承自Comparable接口,其中定义了compareTo()方法.各方法作用如下:

  • getDelay(): 规定当前元素的延时,Delay类型的元素必须要等到其延时过期后才能从容器中取出,提前取会取不到.
  • compareTo(): 规定元素在容器中的排列顺序,按照compareTo()的结果升序排列.

Delayqueue可以用来执行定时任务.

public class T {

    // 定义任务类,实现Delay接口
	static class MyTask implements Delayed {
        private long runningTime;	// 定义执行时间

        public MyTask(long runTime) {
            this.runningTime = runTime;
        }

        // 执行时间减去当前时间 即为延迟时间
        @Override
        public long getDelay(TimeUnit unit) {
            return unit.convert(runningTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }

        // 规定任务排序规则: 先到期的元素排在前边
        @Override
        public int compareTo(Delayed o) {
            return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
        }

        @Override
        public String toString() {
            return "MyTask{" + "runningTime=" + runningTime + '}';
        }
    }

    
    public static void main(String[] args) throws InterruptedException {
        long now = System.currentTimeMillis();

        // 初始化延迟队列,并添加五个任务,注意任务不是按照顺序加入的
        DelayQueue<MyTask> tasks = new DelayQueue<>();
        tasks.put(new MyTask(now + 1000));
        tasks.put(new MyTask(now + 2000));
        tasks.put(new MyTask(now + 1500));
        tasks.put(new MyTask(now + 2500));
        tasks.put(new MyTask(now + 500));
        
        // 从输出可以看到我们规定的compareTo()排序方法进行排序的
        System.out.println(tasks);  

        // 取出所有任务,因为要一段时间后才能取出,因此需要take()方法阻塞式地取
        while (!tasks.isEmpty()) {
            System.out.println(tasks.take());
            // System.out.println(tasks.remove());	// 使用remove()取元素会发生异常
            // System.out.println(tasks.poll());	// 使用poll()会轮询取元素,效率低
        }
    }
}

程序输出如下,我们发现延迟队列中的元素按照compareTo()结果升序排列,且5个元素都被阻塞式的取出

[MyTask{runningTime=1563667111945}, MyTask{runningTime=1563667112445}, MyTask{runningTime=1563667112945}, MyTask{runningTime=1563667113945}, MyTask{runningTime=1563667113445}]
MyTask{runningTime=1563667111945}
MyTask{runningTime=1563667112445}
MyTask{runningTime=1563667112945}
MyTask{runningTime=1563667113445}
MyTask{runningTime=1563667113945}
阻塞消费队列TransferQueue

TransferQueue继承自BlockingQueue,向其中添加元素的方法除了BlockingQueueadd(),offer(),put()之外,还有一个transfer()方法,该方法会使当前线程阻塞直到消费者将该线程消费为止.

transfer()put()的区别: put()方法会阻塞直到元素成功添加进队列,transfer()方法会阻塞直到元素成功被消费.

TransferQueue特有的方法如下:

  • transfer(E): 阻塞当前线程直到元素E成功被消费者消费

  • tryTransfer(E): 尝试将当前元素送给消费者线程消费,若没有消费者接受则返回false且放弃元素E,不将其放入容器中.

  • tryTransfer(E,long,TimeUnit): 阻塞一段时间等待消费者线程消费,超时则返回false且放弃元素E,不将其放入容器中.

  • hasWaitingConsumer(): 指示是否有阻塞在当前容器上的消费者线程

  • getWaitingConsumerCount(): 返回阻塞在当前容器上的消费者线程的个数

public static void main(String[] args) {

    // 创建链表实现的TransferQueue
    TransferQueue transferQueue = new LinkedTransferQueue();

    // 启动消费者线程,睡五秒后再来消费
    new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(5);
            System.out.println(transferQueue.take());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();


    // 启动生产者线程,使用transfer()方法添加元素 会阻塞等待元素被消费
    new Thread(() -> {
        try {
            // transferQueue.put("product");	// 使用put()方法会阻塞等待元素成功加进容器
            transferQueue.transfer("product"); 	// 使用transfer()方法会阻塞等待元素成功被消费
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

运行程序,我们发现生产者线程会一直阻塞直到五秒后"product2"被消费者线程消费.

零容量的阻塞消费队列SynchronousQueue

SynchronousQueue是一种特殊的TransferQueue,特殊之处在于其容量为0. 因此对其调用add(),offer()方法都会使程序发生错误(抛出异常或阻塞线程).只能对其调用put()方法,其内部调用transfer()方法,将元素直接交给消费者而不存储在容器中.

public static void main(String[] args) {

    BlockingQueue synchronousQueue = new SynchronousQueue();

    // 启动消费者线程,睡五秒后再来消费
    new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(5);
            System.out.println(synchronousQueue.take());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();


    System.out.println(synchronousQueue.size()); // 输出0

    // 启动生产者线程,使用put()方法添加元素,其内部调用transfer()方法,会阻塞等待元素被消费
    new Thread(() -> {
        try {
            // synchronousQueue.add("product");	// SynchronousQueue容量为0,调用add()方法会报错
            synchronousQueue.put("product");    // put()方法内部调用transfer()方法会阻塞等待元素成功被消费
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

该程序的输出行为与上一程序类似,生产者线程调用put()方法后阻塞五秒直到消费者线程消费该元素.

SynchronousQueue应用场景: 网游的玩家匹配: 若一个用户登录,相当于给服务器的消息队列发送一个take()请求;若一个用户准备成功,相当于给服务器的消息队列发送一个put()请求.因此若玩家登陆但未准备好 或 只有一个玩家准备好 时游戏线程都会阻塞,直到两个人都准备好了,游戏线程才会被唤醒,游戏继续.

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值