JDK8 JUC整理

Posted roykingw

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK8 JUC整理相关的知识,希望对你有一定的参考价值。

一、内容与特点

​ JUC主要是指JDK8中java.util.concurrent里提供的一系列线程并发工具,但是线程并发的问题远不止几个工具这么简单。要学习工具使用,更要能深入理解工具的原理以及处理线程并发问题的思路。

​ 新手学技术、老手学体系,高手学格局。

二、理解线程

1、线程与进程

​ 进程是操作系统中分配资源的最小单位,线程是CPU调度运行的最小单位。同一个进程下分配多个线程,这些线程可以共享进程内的资源。

​ 现在的CPU都是有多个核心独立运行的,所以使用多线程能够更多的使用CPU的性能。并且CPU的计算速度非常非常快,比我们程序的执行速度快很多。多线程情况下,CPU可以在多个线程之间轮转切换执行,也能进一步提高CPU的使用率。

2、为什么线程不是越多越好?

​ 1:线程执行需要消耗CPU资源,即使线程休眠,依然会要消耗CPU资源。线程过多,就会提高CPU的使用率,加大CPU的负担。

​ 2:CPU运行过程中会在线程之间不断切换,切换过程中,需要不断的切换线程上下文。切换过程中的资源消耗不大,但是如果线程过多,这个消耗就会更大,CPU的使用率反而降低了。

​ 3:经常听说一句话:频繁的开启线程,需要分配线程ID,并将相关上下文保存下来。在线程切换过程中也需要进行上下文切换,还原线程运行的环境,这样会消耗系统资源。

​ 有个简单的比喻,CPU就好比一个饭店的一批食客(多个CPU核心),他们只负责吃东西(计算数据),饭店(操作系统)给他们上什么他就吃什么。而线程就好比是服务员,负责给CPU上菜(分配计算资源)。这个食客吃东西非常快,合理的增加服务员数量,确实可以提高饭店的运行效率。但是如果服务员太多,就需要增加非常多的碗碟,这一批食客也需要在一大堆的碗碟当中去分配食物,反而浪费了非常多的吃饭的时间,整体效率自然就降低了。

​ 所以线程并不是越多越好,需要合理的规划线程数量。通常在Java中,可以通过Runtime.getRuntime().availableProcessors();获取当前机器的CPU核心数。而在对程序进行线程规划时,对于数据计算比较多的进程,称为CPU密集型。这类进程相当于小碗菜,菜种类多和分量少,临时的中间变量也会非常多。规划的线程数最好和CPU核心数一样,这就相当于在饭店中合理的减少碗碟的数量。而对于数据读取比较多的进程,称为IO密集型。这类进程相当于大锅菜,菜的种类不多但是分量很大,临时的中间变量会比较少。这时规划的线程数可以相对多一点,达到CPU核心数的两倍都是可以接受的,这就相当于大碗传菜,碗碟相对不会太多,增加服务员更能提高执行效率。

​ 同时,还可以使用一些技术如池化技术来进一步提高线程的使用率。这就相当于调整服务员的排班,让服务员的工作时间更紧凑,工作效率更高,同时也能减少服务员的个数。

3、Java中开启线程的方式

​ Runnable:没有返回值的线程任务。

new Thread(()->{},'Thread1').start()

java可以开启线程吗? java开启线程是通过调用native本地方法,调用JVM的C/C++代码来启动线程。

另外,还有一种类似的异步任务Callable经常拿来跟Runnable比较。Callable是一种有返回值的任务。

FutureTask<String> futureTask = new FutureTask<>(()->{return "callable"});
futureTask.run();
futureTask.get();

但是futureTask实际上是在run()方法阻塞执行的。

public class CallAbleDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException {
        final FutureTask<String> futureTask = new FutureTask<>(() -> {
            Thread.sleep(10000);
            return "call";
        });
        new Thread(()->{futureTask.run();}).start();
        try{
            System.out.println(futureTask.get(5, TimeUnit.SECONDS));
        }catch (TimeoutException e){
            System.out.println(e.getMessage());
            System.out.println(futureTask.get(6, TimeUnit.SECONDS));
        }
    }
}

三、理解线程安全问题

1、什么是线程安全问题?

多个线程操作同一个资源就会造成线程安全问题。

public class SaleTicketDemo1 {
    public int ticket = 100 ;
    public void saleTicket(){
        ticket --;
        System.out.println(Thread.currentThread().getName()+"; ticket = "+ ticket);
    }
    public static void main(String[] args) {
        final SaleTicketDemo1 saler = new SaleTicketDemo1();
        for (int i = 0; i < 10; i++) {
            new Thread(()->{saler.saleTicket();},""+i).start();
        }
    }
}

会发现ticket减少的顺序是乱的,并且会有重复的问题。

​ 线程安全问题的根源在于多个线程同时争抢同一个内存资源,造成线程工作的结果未能及时通知给其他线程。

在这里插入图片描述

2、如何解决线程安全问题?

​ 解决线程安全问题的方法就是要规范线程对资源的操作顺序。主要有两种,一种是针对资源加锁,让线程有序的竞争对资源的操作。另一种是针对线程,让线程进行有序排队。

1、针对资源加锁:

​ 加锁有两种常用的方法,一种是使用Java提供的Synchronized关键字自动加锁。另一种是使用JUC包中的Lock对象,手动加锁。

对于Synchronized关键字:使用方法比较简单,可以在后面加一个作为锁使用的对象。任何的java对象都可以作为锁来使用。持有该对象的线程才能够去执行后面受保护的代码。

synchroniezd(obj){ protected code}

​ 另外Synchronized关键字,可以直接加在方法描述上。如果是static方法,表示对Class加锁,如果是普通方法,表示对该对象加锁。例如对上面示例中的saleTicket()方法前面加上一个synchronized关键字,整个执行结果就会是正确且有序的。

关于锁的本质其实只是在对象的头部markdown部分加一个描述符。根据资源竞争激烈程度分为无锁、偏向锁、轻量级锁、重量级锁。 参见 https://blog.csdn.net/roykingw/article/details/107389045

对于Lock锁:是java.util.concurrent包中提供的一个顶层接口,由此提供了一系列的同步锁工具。最为常用的实现类就是ReentrantLock,可重入锁。

public class SaleTicketDemo1 {
    private Lock lock = new ReentrantLock();
    public int ticket = 100 ;

    public void saleTicket(){
        lock.lock();
        try{
            ticket --;
            System.out.println(Thread.currentThread().getName()+"; ticket = "+ ticket);
        }finally {
            lock.unlock();
        }
    }
    .....
}

​ 关于Lock锁,在JDK中的实现类大致如下:

在这里插入图片描述

​ 其中Lock接口提供了基础的上锁lock与unlock操作。他的一个重要子类ReentrantLock是一个可重入锁的实现。可重入的概念从字面上就能看到,表示可以对这个锁对象多次上锁。需要注意的是多次上锁也需要多次解锁。

​ 另外,在ReentrantLock当中,可以通过扩展出多个Condition来扩展锁的使用范围。

关于ReentrantLock,是JUC当中很重要的一个实现类。内部使用AQS框架实现了公平锁与非公平锁。公平锁表示排队的线程不能插队。在构造ReentrantLock时,就可以传入一个boolean类型的参数,默认是false表示是非公平锁。传入true表示是公平锁。在后面AQS部分还会回过头来分析ReentrantLock。

​ 然后还一个ReadWriteLock接口,提供了读写锁分离的实现。提供一个readLock()方法获取读锁,还提供一个writeLock()方法获取写锁。 读写锁可以分开操作。在提供的ReentrantReadWriteLock实现类中,提供了读写锁的具体实现。

主要的实现思想就是,

  • 对同一个锁对象,写锁是独占锁,同一时刻只能一个线程写,其他线程需要排队。
  • 而读锁是共享锁,同一时刻可以有多个线程同时读。
  • 读锁与写锁不能同时存在。

2、让线程合理的排队:

public class SaleTicketDemo2 {
    public int ticket = 100;
    public void saleTicket(){
        if(ticket >0) {
            this.ticket--;
            System.out.println(Thread.currentThread().getName() + "; ticket = " + ticket);
        }
    }
    public static void main(String[] args) throws InterruptedException {
        SaleTicketDemo2 demo = new SaleTicketDemo2();
        Semaphore s1 = new Semaphore(1);
        Semaphore s2 = new Semaphore(1);
        s1.acquire();
        new Thread(()->{
            while(demo.ticket >0){
                try{
                    s1.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                demo.saleTicket();
                s2.release();
            }
        },"saler1").start();
        new Thread(()->{
            while(demo.ticket >0){
                try{
                    s2.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                demo.saleTicket();
                s1.release();
            }
        },"saler2").start();
    }
}

没有使用锁,让两个线程交替进行。

public class SaleTicketDemo3 {
    public volatile int ticket = 20;
    public void saleTicket(){
        if(ticket >0) {
            this.ticket--;
            System.out.println(Thread.currentThread().getName() + "; ticket = " + ticket);
        }
    }
    public static int number = 0 ;
    public static void main(String[] args) {
        SaleTicketDemo3 demo = new SaleTicketDemo3();
        for (int i = 0; i < 10; i++) {
            int current = i;
            new Thread(()->{
                while(demo.ticket >0){
                    if(number == current){
                        demo.saleTicket();
                        number++;
                        if(number==10){
                            number = 0 ;
                        }
                    }
                }
            },"saler"+i).start();
        }
    }
}

实现了十个线程依次有序售票的效果。这个示例中要注意对ticket的定义,volatile关键字是很重要的,没有volatile关键字的话,整个程序大概率不能正常结束。volatile关键字是干什么用的?记得吗?别急,后面会再梳理这个关键字。

​ 有人可能会觉得这种让线程排序的方式会将并行转为串行,会影响效率。其实这问题基本上是不存在的。因为对于每一个CPU核心来说,他处理问题是通过时间片轮换的方式进行的,本质上来说只能是串行的。只是CPU执行速度非常快,才产生了并行的效果。所以如果让线程合理的进行排序,对效率影响不会很大。

​ 当然,让线程乖乖的排序并不容易,也正是这样才体现出Doug Lea大师带来的JUC的强大。实际上,JUC中很大一部分的功能就是通过对线程进行合理的排序来解决的。在后面的部分会再做深入的介绍。

四、JDK集合工具并发思路

​ 在JDK提供的集合工具中,针对线程安全其实做了非常多的设计。常用的思路有以下几种。

1、加锁

​ 即便是在JDK源码中,加锁依然是解决线程安全问题做常用的方式。

​ 比如常用的ArrayList,在多线程下是不安全的。例如

public class ArrayListUnsafeDemo {
    public static List arrayList = new ArrayList();
    public static void main(String[] args) {
        Thread []threadArray = new Thread[1000];
        //启动多线程,操作同一个ArrayList
        for(int i=0;i<threadArray.length;i++){
            threadArray[i] = new Thread(()->{
                ArrayListUnsafeDemo.arrayList.add(Thread.currentThread().getName());
            });
            threadArray[i].start();
        }
        //等待所有线程结束
        for(int i=0;i<threadArray.length;i++){
            try {
                threadArray[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //打印ArrayList结果
        for(int i=0;i<arrayList.size();i++){
            System.out.println(arrayList.get(i));
        }
    }
}

在执行这段代码的过程中,会出现三种可能的线程安全问题,1、输出值为null,2、某些线程没有输出值,3、数组越界异常。

然后下面这个示例会抛出java.util.ConcurrentModificationException异常

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        for (int i = 1; i <= 30; i++) {
            new Thread(() -> {
                list.add(UUID.randomUUID().toString().substring(0, 8));
                System.out.println(list);
            }).start();
        }
    }

而他抛出异常的地方在这个方法

private void checkForComodification() {
            if (ArrayList.this.modCount != this.modCount)
                throw new ConcurrentModificationException();
        }

其实在每次对数据做操作时都会同步这两个modCount,而之所以会出现不相等的情况就是因为在多线程情况下,会出现同步代码未执行的情况。

​ 而JDK给出的一个线程安全的实现是Vector。Vector中一些主要的修改数据的方式都是加了sychronized关键字,以这种方式来实现线程安全,会将多线程的并行运行改为串行运行,会降低应用的执行效率,所以官方也是不推荐使用Vector。

2、CAS

​ 关于CAS,CAS在底层会通过线程自旋的方式进行资源竞争,但是在Java中,CAS操作只是Unsafe类中的一个同步方法而已。在Java中通常会通过for(;;)这样的操作来保证线程不停止。但是本质上,这只是一个循环而已,跟线程的自旋不是一个概念。另外,CAS操作本身其实并不能保证操作的原子性,Java中CAS操作能保证线程安全很大程度上是因为底层JVM的支持。

​ 例如对于JDK的Map接口,关于线程安全问题,主要对比HashMap、HashTable和ConcurrentHashMap。

​ 对于Map最常用的实现类就是HashMap。HashMap通过一个Node<K,V>[] table数组来维护数据HashEntry,而每一个Node会有一个next属性指向他后面的Node。每次put数据,会将key进行hash计算,获取hashcode后,以取模的方式分配到对应的一个Node后面。而如果发生Hash碰撞,就会将当前Node添加到对应Hash位置的Node的后面,形成一个链表结构。而如果某一个Node后面的链表长度大于8(不包括8,由一个内部参数TREEIFY_THRESHOLD=8定义),会将整个链表结构转换成红黑树结构,以提高查询数量。进行remove操作删除数据时,如果对应的红黑树节点个数小于等于6(包括6,由一个内部参数UNTREEIFY_THRESHOLD=6定义),会将整个红黑树结构再退化成链表结构。 HashMap中有一个参数DEFAULT_LOAD_FACTOR,加载因子,默认是0.75。这表示当Map中的数据容量超过最大容量的75%之后,HashMap就会进行扩容,每次扩容按照2的指数倍进行数量扩容。

​ 但是HashMap是线程不完全的,他的线程不安全主要体现在扩容的过程当中。在多线程情况下,HashMap在容量扩容的过程中会有几率形成环形引用,会导致get获取数据时出现死循环。

​ JDK并没有从根本上尝试去解决HashMap的线程安全问题,因为要解决线程安全问题,就必然会降低HashMap的运行效率。JDK通过提供另外的Map实现类来解决HahMap的线程安全问题。

​ HashTable实现了HashMap的线程安全操作,但是他的实现方式是对所有的关键操作加sychronized关键字上锁来保证的。这种方式每次操作都是对当前对象上锁,将多线程的并行操作转换成了串行操作。但是这样一方面会造成执行效率低下,另外也无法完全解决多线程的安全问题。

Map<String,Object> map = new HashTable<>();
if(map.containsKey("aaa")){  // 锁当前对象
	map.put("aaa",value); // 
}

​ 例如对于上面这种常用的符合操作,map.containsKey和map.put两个操作是两个不同的锁步骤,在多线程情况下,会分开进行两次不同的锁竞争过程,整体上依然会有线程安全问题。

​ HashTable并不靠谱,所以JDK又提供了另外一个线程安全的实现版本ConcurrentHashMap。

​ 在1.7版本及以前,ConncurrentHashMap通过Segment实现分段加锁。Segment是ReentrantLock的一个子类。在内部会维护一个Segment数组,一个Segment对一部分的HashEntry进行加锁保护。也就是说在ConncurrentHashMap中会持有一个锁数组,每次只针对一部分的数据HashEntry进行加锁,这样相比HashTable就能够提供更细粒度的锁,提高并发性能。

​ 而在1.8版本之后,ConncurrentHashMap去掉了分段锁的支持,改为使用CAS+synchronized的方式实现线程安全。并对Node对象的当前值属性val和下一个Node指针next增加volatile关键字保证多线程可见性。整体并发性能得到进一步提升。例如在ConcurrentHashMap中,put操作添加一个不存在的元素时,会使用cas操作保证只有一个线程能够添加新成功。(这个casTabAt方法就是使用的cas操作。)

	else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }

而在后面对已有的值进行更新时,则会使用synchronized关键字来保证线程安全性。

3、CopyOnWrite

​ CopyOnWrite写时复制也是JDK中解决线程安全问题的一种机制。这种思想就是在发现对数据的写操作反生时,就会为每个调用者创建一个专有副本。每个调用者在写数据时会先将数据在副本上完成修改。在修改完成后再整体同步到原始资源中。而对于所有读数据的调用者,只需要关注同一份数据,而不用考虑其他调用者在修改数据过程中产生的中间状态。在JDK8中提供了CopyOnWriteArrayList 和CopyOnWriteArraySet两种实现。

​ 例如CopyOnWriteArrayList的add方法就会在写出过程中创建副本。

public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

这里锁的意义是用来保证副本数据写回的过程中不会发生混乱。

使用CopyOnWrite后,他的读数据操作就可以是一个无锁的普通操作。因为他们读的始终是同一份没有中间状态的数据。

public E get(int index) {
    return get(getArray(), index);
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}    
    

五、JDK并发工具

​ JDK中提供了几个并发工具,能够很好的简化对线程进行各种排队逻辑的编程模型。常用的有三个,CountDownLatch,CyclicBarrier,Semaphore。另外还有一个用得不是很多的SynchronousQueue。

1、CountDownLatch

​ 可以理解为赛跑,大家都在起跑线上等着,进行5,4,3,2,1倒数,倒数到0时,所有线程就一起开跑。这在我们需要模拟高并发操作时非常有用。我们平常的手段,例如多线程、for循环、线程休眠唤醒等,都很难模拟到CPU级别的并发操作。而使用CountDownLatch就非常容易实现。

//使用CountDownLatch模拟高并发
public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDown = new CountDownLatch(1);
    	//5个线程启动时间不同
        for (int i = 0; i < 5; i++)以上是关于JDK8 JUC整理的主要内容,如果未能解决你的问题,请参考以下文章

JUC学习之线程安全集合类

IOS开发-OC学习-常用功能代码片段整理

JUC并发编程 共享模式之工具 JUC 读写锁 StampedLock -- 介绍 & 使用

VS2015 代码片段整理

这一文道尽JUC的ConcurrentHashMap

这一文道尽JUC的ConcurrentHashMap