Java并发编程基础篇(三)——其他JUC并发工具类的使用方法

Java并发编程基础篇(三)——其他JUC并发工具类的使用方法

除了上一篇中提到的各类锁之外,JUC包也提供了其他可用于并发场景下的同步工具,包括AtomicInteger等原子操作类、CountDownLatch等并发工具类、ConcurrentHashMap等并发集合类。本篇将会重点讲述这类并发工具的概念与使用方法,并简要介绍线程池的使用方法。

1、原子操作类

java.util.concurrent.atomic包(简称Atomic包)提供了4种类型、12个类的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。
Atomic包里的类基本都是使用Unsafe实现的包装类。
以AtomicInteger为例,常用方法如下:

  • int addAndGet(int delta):以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果。
  • boolean compareAndSet(int expect,int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。
  • int getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值。
  • void lazySet(int newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

利用AtomicInteger可以保证并发场景下的安全性,例如编写一个多线程安全的全局唯一ID生成器:

class IdGenerator {
     
    AtomicInteger var = new AtomicInteger(0);

    public int getNextId() {
     
        return var.getAndIncrement();
    }
}

AtomicInteger如何保证其各个方法属于原子操作呢?主要是通过CAS实现,以getAndIncrement()为例:

public final int getAndIncrement() {
     
        for (;;) {
     
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return current;
        }
}
public final boolean compareAndSet(int expect, int update) {
     
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

CAS(Compare and Set)是指,在这个操作中,先检查当前数值是否等于current。如果AtomicInteger的当前值不是cunrrent,就意味着AtomicInteger的值没有被其他线程修改过,则将AtomicInteger的当前数值更新成next的值,如果不等compareAndSet方法会返回false,程序会进入for循环重新进行compareAndSet操作。

2、并发工具类

JUC包提供了多种有用的并发工具类,其中CountDownLatch、CyclicBarrier和
Semaphore工具类提供了一种并发流程控制的手段,Exchanger工具类则提供了在线程间交换数据的一种手段。

2.1、CountDownLatch

CountDownLatch能够使一个或多个线程在等待另外一些线程完成操作之后,再继续执行。
CountDownLatch的构造函数接收一个int类型的参数作为计数器,传入N就代表等待N个点完成。当调用CountDownLatch的countDown方法时,N就会减1,CountDownLatch的await方法会阻塞当前线程,直到N变成零。
CountDownLatch的一个非常典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个CountDownLatch对象的await()方法,其他的任务执行完自己的任务后调用同一个CountDownLatch对象上的countDown()方法,这个调用await()方法的任务将一直阻塞等待,直到这个CountDownLatch对象的计数值减到0为止。
下面这个Demo展示了创建一个计数器值为3的CountDownLatch对象,创造3个工作线程,每个工作线程在完成操作后各自使CountDownLatch的计数器值减1,而主线程会在等待计数器值归零后继续进行其他操作。

public class CountDownLatchDemo {
     

	public static void main(String[] args) throws InterruptedException {
     
		// 指定全局唯一的CountDownLatch对象,计数为3
		CountDownLatch latch = new CountDownLatch(3);

		long start = System.currentTimeMillis();

		// 创造3个不同的工作线程,每个线程都持有该latch对象
		WorkerThread first = new WorkerThread(1000, latch, "worker-1");
		WorkerThread second = new WorkerThread(2000, latch, "worker-2");
		WorkerThread third = new WorkerThread(3000, latch, "worker-3");

		first.start();
		second.start();
		third.start();

		// await方法会阻塞当前线程,直到计数器latch变成零
		latch.await();

		// 计数器归零后,主线程继续其他操作
		System.out.println(Thread.currentThread().getName() + " has finished. Spend Time = " + (System.currentTimeMillis() - start));
	}

	// 定义工作线程类,传入CountDownLatch对象
	static class WorkerThread extends Thread {
     

		private int delay;
		private CountDownLatch latch;

		public WorkerThread(int delay, CountDownLatch latch, String name) {
     
			super(name);
			this.delay = delay;
			this.latch = latch;
		}

		@Override
		public void run() {
     
			try {
     
				Thread.sleep(delay);
				// 调用countDown方法使计数器值减1
				latch.countDown();
				System.out.println(Thread.currentThread().getName() + " finished");
			} catch (InterruptedException e) {
     
				e.printStackTrace();
			}
		}
	}
}

和synchronized、Condition中的wait方法、await方法一样,可以使用await(long time,TimeUnit unit)指定等待特定时间后,就会不再阻塞当前线程。
最后,上面的例子通过Thread.sleep()方法避免各个线程同时修改CountDownLatch,在许多情况下可能需要对调用countDown方法做同步处理,例如:

public class Parallellimit {
     
    public static void main(String[] args) {
     
        ExecutorService pool = Executors.newCachedThreadPool();
        CountDownLatch cdl = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
     
            CountRunnable runnable = new CountRunnable(cdl);
            pool.execute(runnable);
        }
    }
}

 class CountRunnable implements Runnable {
     
    private CountDownLatch countDownLatch;
    public CountRunnable(CountDownLatch countDownLatch) {
     
        this.countDownLatch = countDownLatch;
    }
    @Override
    public void run() {
     
        try {
     
            synchronized (countDownLatch) {
     
                /*** 每次减少一个容量*/
                countDownLatch.countDown();
                System.out.println("thread counts = " + (countDownLatch.getCount()));
            }
            countDownLatch.await();
            System.out.println("concurrency counts = " + (100 - countDownLatch.getCount()));
        } catch (InterruptedException e) {
     
            e.printStackTrace();
        }
    }
}

2.2、同步屏障CyclicBarrier

CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier),可以让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。
CyclicBarrie的构造方法包括CyclicBarrier(int parties)和CyclicBarrier(int parties,Runnable barrierAction),parties参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarrier我已经到达了屏障,然后当前线程被阻塞,可以类比为CountDownLatch的计数器初始值;而第二个构造方法中的barrierAction参数可以用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景。
下面的这个例子中,我们创建了一个CyclicBarrier拦截4个不同的线程,并且在所有线程都已经到达屏障时,打印最后一个到达屏障的线程的名称以及其他信息:

public class CyclicBarrierDemo {
     
    static class TaskThread extends Thread {
     

        CyclicBarrier barrier;

        public TaskThread(CyclicBarrier barrier) {
     
            this.barrier = barrier;
        }

        @Override
        public void run() {
     
            try {
     
                Thread.sleep(1000);
                System.out.println(getName() + " 到达栅栏 A");
                barrier.await();
                System.out.println(getName() + " 冲破栅栏 A");

                Thread.sleep(2000);
                System.out.println(getName() + " 到达栅栏 B");
                barrier.await();
                System.out.println(getName() + " 冲破栅栏 B");

                Thread.sleep(3000);
                System.out.println(getName() + " 到达栅栏 C");
                barrier.await();
                System.out.println(getName() + " 冲破栅栏 C");
            } catch (Exception e) {
     
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
     
        int threadNum = 4;
        CyclicBarrier barrier = new CyclicBarrier(threadNum, new Runnable() {
     

            @Override
            public void run() {
     
                System.out.println(Thread.currentThread().getName() + " 完成最后任务");
            }
        });

        for(int i = 0; i < threadNum; i++) {
     
            new TaskThread(barrier).start();
        }
    }
}

最终输出结果如下:

Thread-1 到达栅栏 A
Thread-2 到达栅栏 A
Thread-3 到达栅栏 A
Thread-0 到达栅栏 A
Thread-0 完成最后任务
Thread-0 冲破栅栏 A
Thread-1 冲破栅栏 A
Thread-3 冲破栅栏 A
Thread-2 冲破栅栏 A
Thread-0 到达栅栏 B
Thread-2 到达栅栏 B
Thread-1 到达栅栏 B
Thread-3 到达栅栏 B
Thread-3 完成最后任务
Thread-3 冲破栅栏 B
Thread-2 冲破栅栏 B
Thread-0 冲破栅栏 B
Thread-1 冲破栅栏 B
Thread-2 到达栅栏 C
Thread-0 到达栅栏 C
Thread-1 到达栅栏 C
Thread-3 到达栅栏 C
Thread-3 完成最后任务
Thread-0 冲破栅栏 C
Thread-3 冲破栅栏 C
Thread-2 冲破栅栏 C
Thread-1 冲破栅栏 C

可以看到我们让CyclicBarrier调用了3次await方法,事实上形成了3个不同的屏障。这里也能看出CyclicBarrier和CountDownLatch的一大区别,即CyclicBarrier 是可循环利用的。二者之间的差别可以总结如下:

CountDownLatch CyclicBarrier
一次性的 可循环利用的
是线程组之间的等待,即一个(或多个)线程等待N个线程完成某件事情之后再执行;各个线程的职责是不一样的,有的在倒计时,有的在等待倒计时结束 是线程组内的等待,即每个线程相互等待,即N个线程都被拦截之后,然后依次执行;各个线程职责是一样的
计数器由使用者控制 计数器由自己控制
线程调用await方法只是将自己阻塞而不会减少计数器的值 线程调用await方法不仅会将自己阻塞还会将计数器减1
构造方法中的barrierAction参数可以用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景
提供其他有用的方法,比如getNumberWaiting方法可以获得Cyclic-Barrier阻塞的线程数量。isBroken()方法用来了解阻塞的线程是否被中断

2.3、控制并发线程数的Semaphore

Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
例如,我们希望只有10个线程能够进入临界区,可以创造一个许可证数量为10的Semaphore对象,线程在进入临界区时调用Semaphore对象的acquire方法,在离开临界区时调用其release方法;其中:
acquire方法:线程进入临界区时,需要获取许可证,如果许可证数量大于0,则使许可证数量减1,并且使线程进入临界区;否则线程将进入等待状态;
release方法:线程离开临界区时,需要归还许可证,使许可证数量加1。

public class SemaphoreTest {
     
    private static final int THREAD_COUNT = 30;
    private static ExecutorServicethreadPool = Executors.newFixedThreadPool(THREAD_COUNT);
    private static Semaphore s = new Semaphore(10);
    public static void main(String[] args) {
     
        for (inti = 0; i< THREAD_COUNT; i++) {
     
            threadPool.execute(new Runnable() {
     
                @Override
                public void run() {
     
                    try {
     
                        s.acquire();
                        System.out.println("save data");
                        s.release();
                    } catch (InterruptedException e) {
     
                    }
                }
            });
        }
        threadPool.shutdown();
    }
}

在代码中,虽然有30个线程在执行,但是只允许10个并发执行。Semaphore的构造方法
Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允许10个线程获取许可证,也就是最大并发数是10。

2.4、线程间交换数据的Exchanger

Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。
在下面的例子中,我们让两个线程分别输入不同的数据,并且通过exchange方法进行互相比较。

public class ExchangerTest {
     
    private static final Exchanger<String> exgr = new Exchanger<String>();
    private static ExecutorServicethreadPool = Executors.newFixedThreadPool(2);
    public static void main(String[] args) {
     
        threadPool.execute(new Runnable() {
     
            @Override
            public void run() {
     
                try {
     
                    String A = "银行流水A"; // A录入银行流水数据
                    exgr.exchange(A);
                } catch (InterruptedException e) {
     
                }
            }
        });
        threadPool.execute(new Runnable() {
     
            @Override
            public void run() {
     
                try {
     
                    String B = "银行流水B"; // B录入银行流水数据
                    String A = exgr.exchange(B);
                    System.out.println("A和B数据是否一致:" + A.equals(B) + ",A录入的是:" + A + ",B录入是:" + B);
                } catch (InterruptedException e) {
     
                }
            }
        });
        threadPool.shutdown();
    }
}

3、并发集合类

如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe)。比如一些不变类String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类是线程安全的;再例如Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
不过在这里,我们重点关注JUC包针对List、Map、Set、Deque等集合提供的并发集合类,可以归纳如下:

集合 非线程安全的类 JUC提供的线程安全的类 其他线程安全的类
List ArrayList CopyOnWriteArrayList Vector/Collections.synchronizedList
Map HashMap ConcurrentHashMap HashTable
Set HashSet CopyOnWriteArraySet
Queue ArrayDeque / LinkedList ConcurrentLinkedQueue ArrayBlockingQueue / LinkedBlockingQueue
Deque ArrayDeque / LinkedList LinkedBlockingDeque

使用这些并发集合与使用非线程安全的集合类完全相同。以ConcurrentHashMap为例:

Map<String, String> map = new ConcurrentHashMap<>();
// 在不同的线程读写:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");

虽然java.util.Collections工具类还提供了一个旧的线程安全集合转换器,例如Collections.synchronizedMap,但事实上是用一个包装类包装了非线程安全的Map,然后对所有读写方法都用synchronized加锁,性能很低;HashTable、Vector也是采用了相同的设计,性能低下。因此原则上更推荐使用JUC包提供的并发集合类。

3.1、ConcurrentHashMap

HashTable容器针对全表加锁,性能较低。而ConcurrentHashMap使用锁分段技术使得在线程安全的前提下实现更高的性能。首先将数据分成一段一段地存储(即不同的Segment),然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
一个ConcurrentHashMap里包含1个Segment数组(默认大小为16),Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色,守护着一个HashEntry数组里的元素。
每个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,用于存储键值对数据。当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。
Java并发编程基础篇(三)——其他JUC并发工具类的使用方法_第1张图片
由于ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素的时候,必须先通过散列算法定位到某个Segment,然后再定位到其下的HashEntry。ConcurrentHashMap会首先使用Wang/Jenkins hash的变种算法对元素的hashCode进行一次再散列,这样可以减少散列冲突,使元素能够均匀地分布在不同的Segment上,从而提高容器的存取效率。假如散列的质量差到极点,那么所有的元素都在一个Segment中,不仅存取元素缓慢,分段锁也会失去意义。

3.1.1、ConcurrentHashMap的get操作

先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素,代码如下:

public V get(Object key) {
     
	int hash = hash(key.hashCode());
	return segmentFor(hash).get(key, hash);
}

整个get过程不需要加锁,除非读到的值是空才会加锁重读。原因是它的get方法里需要使用的共享变量都被定义为volatile类型,例如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值。
在定位元素的代码里可以发现,定位Segment使用的是元素的hashcode通过再散列后得到的值的高位,而定位HashEntry直接使用的是再散列后的值。其目的是避免两次散列后的值一样。

3.1.2、ConcurrentHashMap的put操作

put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必
须加锁。put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放在HashEntry数组里。
在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进
行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

3.1.3、ConcurrentHashMap的size操作

尽管Segment里的全局变量count是一个volatile变量,相加时
可以获取每个Segment的count的最新值,但是如果直接将各个Segment中的count简单相加,可能累加前使用的count发生了变化,那么统计结果就不准了。
ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。
那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

3.2、ConcurrentLinkedQueue

实现一个线程安全的队列有两种方式:一种是使用阻塞算法,另一种是使用非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现。非阻塞的实现方式则可以使用循环CAS的方式来实现。
ConcurrentLinkedQueue是一个基于链接节点的无界的、线程安全的、先进先出队列,采用了CAS来实现。
ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和指向下一个节点(next)的引用组成。默认情况下head节点存储的元素为空,tail节点等于head节点。
Java并发编程基础篇(三)——其他JUC并发工具类的使用方法_第2张图片

3.3、Java中的BlockingQueue

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法:
1)支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满。
2)支持阻塞的移除方法:在队列为空时,获取元素的线程会等待队列变为非空。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。
JDK 7提供了7个阻塞队列,如下。

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

你可能感兴趣的:(Java,SE,Java并发,多线程,java,并发,juc)