[Java并发] 2. 并发容器

本文深入探讨Java并发编程的关键概念,包括线程安全的单例模式实现方式、高并发下的售票问题解决方案,以及多种并发容器的特性与应用场景,如ConcurrentHashMap、CopyOnWriteArrayList和各种阻塞队列。
摘要由CSDN通过智能技术生成

[Java并发] 2. 并发容器

一、线程安全的单例模式

单例模式就是说系统中对于某类只能有一个实例对象,不能出现第二个!面试中常常会被问到或者手写一个线程安全的单例模式,主要考察多线程情况下的线程安全问题。

1. 不使用同步锁
//直接加载。缺点:在该类加载的时候就会直接new一个静态对象出来
//当系统中这样的类较多时,就使得启动速度变慢。
public class SingletonDirectlyNew {
	//直接初始化一个对象
    private static SingletonDirectlyNew single = new SingletonDirectlyNew();  
    //构造方法私有,保证其他类对象不能直接new一个该对象的实例
    private SingletonDirectlyNew() {         
    
    }
    public static SingletonDirectlyNew getSingle(){ //该类唯一的一个public方法
        return single;
    }
}

上述代码中的一个缺点是该类加载的时候就会直接new一个静态对象出来,当系统中这样的类较多时,会使得启动速度变慢 。现在流行的设计都是讲延迟加载,我们可以在第一次使用的时候才初始化第一个该类对象。所以这种适合在小系统。

2. 延迟/懒加载 使用同步方法
/*锁住了一个方法,锁的力度有点大*/
public class SingletonSynMethod {
    private static SingletonSynMethod instance;
    private SingletonSynMethod() {  //构造方法私有
        
    }
    public static synchronized SingletonSynMethod getInstance() {  //对获取实例的方法进行同步
        if (instance == null) {
            instance = new SingletonSynMethod();
        }
        return instance;
    }
}

上述代码中的一次锁住了一个方法, 这个粒度有点大 ,改进就是只锁住其中的new语句就OK。就是所谓的“双重锁”机制。

3. 延迟/懒加载 使用双重同步锁
public class SingletonDoubleSyn {
    private static SingletonDoubleSyn instance;
    private SingletonDoubleSyn () {  //构造方法私有
        
    }
    public static SingletonDoubleSyn getInstance() {
        if (instance == null) {
            synchronized (SingletonDoubleSyn.class) {  //锁定new语句
                if (instance == null) {
                    instance = new SingletonDoubleSyn();
                }
            }
        }
        return instance;
    }
}
4. 延迟/懒加载 使用静态内部类
/*不用加锁 也能实现懒加载*/
public class SingletonInner {
    private SingletonInner() {
 
    }
    private static class Inner { //静态内部类
        private static SingletonInner s = new SingletonInner();
    }
    private static  SingletonInner getSingle() {
        return Inner.s;
    }
}

既不用加锁,也能实现懒加载,使用内部类,只加载一次。

二、售票问题

写一个程序模拟:有n张火车票,每张票都有一编号,同时有10个窗口在对外售票。

1. List

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

/*下面程序模拟卖票可能会出现两个问题:①票卖重了 ②还剩最后一张票时,好几个线程同时抢,出现-1张票
 *出现上面两个问题主要是因为:①remove()方法不是原子性的 ②判断+操作不是原子性的*/
import java.util.ArrayList;
import java.util.List;
public class TicketSeller1 {
	static List<String> tickets = new ArrayList<String>();
	
	static {
		for (int i=0; i<10000; i++) tickets.add("票编号:" + i);//共一万张票
	}
	
	public static void main(String[] args) {
		for (int i=0; i<10; i++) {//共10个线程卖票
			new Thread(() -> {
				while(tickets.size() > 0) {//判断余票
					System.out.println("销售了--" + tickets.remove(0));//操作减票
				}
			}).start();
		}
	}
}
2. Vector

Vector本身就是同步容器,所有的方法都加锁,所有操作均为原子性。但仍会出现问题,因为判断与操作是分离的,形成的复合操作不能保证原子性。

/*本程序虽然用了Vector作为容器,Vector中的方法都是原子性的,但是在判断size和减票的中间还是可能被打断的,即被减到-1张*/
import java.util.Vector;
import java.util.concurrent.TimeUnit;
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

使用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. 并发队列

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

import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
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();//poll方法是同步的
                    if (t == null) break;
                    System.out.println("销售了:" + t);
                }
            }).start();
        }
    }
}

poll()是原子性的,但是后面的ifprintln会打破原子性,但是这里没有问题,因为poll()后我们没有对队列进行修改操作(比如前面的remove),不会出现重复和超额销售。

三、并发容器
1. Map

主要的非并发容器有HashMapTreeMapLinkedHashMap

主要的并发容器有HashTableSynchronizedMapConcurrentMap

  • HashTableSynchronizedMap的效率较低,其同步的实现原理类似,都是给容器的所有方法都加锁。其中SynchronizedMap使用装饰器模式,调用其构造方法并传入一个Map实现类,返回一个同步的Map容器。
  • ConcurrentMap的效率较高,有两个实现类
    • ConcurrentHashMap: 使用哈希表实现,key是无序的。
    • ConcurrentSkipListMap: 使用跳表实现,key是有序的。

关于跳表的实现:跳表(SkipList)及ConcurrentSkipListMap源码解析,ConcurrentSkipListMap和Treemap插入时效率比较低,需要排好顺序。但是查的时候效率很高。

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

  • 在JDK1.8以前,其实现同步使用的是分段锁,将整个容器分为16段(Segment),每次操作只锁住操作的那一段,是一种细粒度更高的锁.
  • 在JDK1.8及以后,其实现同步用的是Node+CAS,关于CAS的实现,可以看这篇文章Java:CAS(乐观锁)
import java.util.Arrays;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.CountDownLatch;

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<>(); 			// 使用跳表,高并发且有序
    // Map<String,String> map = new TreeMap<>();                            //插入时要排序,所以插入可能会比较慢

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

    long start = System.currentTimeMillis();
    // 创建100个线程,锁100个门闩,每个线程都往map中加10000个随机字符串,每个完成后打开一个门闩。当所有线程执行完毕,记录所花费的时间。
    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());
}

Map和Set本质上是一样的,只是Set只有key,没有value,所以下面谈到的Map可以替换成Set。

  • 在不加锁的情况下,可以用:HashMap、TreeMap、LinkedHashMap。想加锁可以用Hashtable(用的非常少)。
  • 在并发量不是很高的情况下,可以用Collections.synchronizedXxx()方法,在该方法中传一个不加锁的容器(如Map),它返回一个加了锁的容器(容器中的所有方法加锁)
  • 在并发性比较高的情况下,用ConcurrentHashMap ,如果并发性高且要排序的情况下,用ConcurrentSkipListMap。
2. 写时复制CopyOnWrite

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

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

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

高并发队列分为阻塞队列BlockingQueue和非阻塞队列ConcurrentLinkedQueue,其中阻塞队列的常用实现类有LinkedBlockingQueueArrayBlockingQueueDelayedQueueTransferQueueSynchronousQueue;非阻塞队列使用CAS保证操作的原子性,不会因为加锁而阻塞线程.类似于ConcurrentMap

3.1 高并发队列的方法
方法抛出异常返回特殊值一直阻塞 (非阻塞队列不可用)阻塞一段时间 (非阻塞队列不可用)
插入元素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,并放弃当前错误操作,不抛出异常。
3.2 经典阻塞队列LinkedBlockingQueue和ArrayBlockingQueue

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

import java.util.Random;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

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();
    }
}
3.3 延迟队列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}
3.4 阻塞消费队列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"被消费者线程消费.

适用场景:消费者先启动,生产者生产一个东西的时候,不扔在队列里,而是直接去找有没有消费者,有的话直接扔给消费者,若没有消费者线程,调用transfer()方法就会阻塞,调用add()、offer()、put()方法不会阻塞。TransferQueue适用于更高的并发情况

3.5 零容量的阻塞消费队列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()请求.因此若玩家登陆但未准备好 或只有一个玩家准备好时游戏线程都会阻塞,直到两个人都准备好了,游戏线程才会被唤醒,游戏继续.

整理自马士兵并发编程视频:地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值