多线程进阶(JUC)

JUC(java.util.concurrent) 这个包里放了很多和多线程开发相关的类

一、Callable接口

Callable 是一个 interface,和Runnable很类似,都可以在用来在创建一个新线程的时候指定一个具体的任务,二者的区别是Callable指定的任务是待返回值的,Runnable则不带返回值

【代码示例1】:计算1+2+3+…+100,不使用Callable

  1. 创建Result类,sum保存最终结果,lock作为锁对象
  2. main方法中先创建一个线程,在该线程中计算1+2+3…+100
  3. 在没有计算出结果之前,主线程通过wait一直阻塞等待
  4. 计算出结果后,通过notify唤醒主线程
package juc;

import java.util.concurrent.Callable;
public class Demo1 {
    static class Result{
        public int sum;
        public Object lock=new Object();
    }
    public static void main(String[] args) throws InterruptedException {
        Result result=new Result();
        Thread t=new Thread(()->{
            int sum=0;
            for (int i = 0; i <=100; i++) {
                sum+=i;
            }
            synchronized (result.lock){
                result.sum=sum;
                result.lock.notify();
            }
        });
        t.start();
        synchronized (result.lock){
            while(result.sum==0){
                result.lock.wait();
            }
            System.out.println(result.sum);
        }
    }
}

可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错

【代码示例2】:使用Callable

  1. 创建一个匿名内部类,实现Callable接口,Callable一个泛型接口,泛型参数表示返回值的类型
  2. 在匿名内部类中重写call方法,在call方法中计算累加结果,并返回最终结构
  3. 通过FutureTask对callable实例进行一层封装
  4. 创建线程,线程的构造方法的传入task实例,新线程会执行task中的callable中的call方法,计算结果会保存在task对象中
  5. 通过task.get()方法拿到返回结果,在没有计算出结果之前,task.get()方法一直阻塞等待
package juc;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo2 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable=new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum=0;
                for (int i = 0; i < 100; i++) {
                    sum+=i;
                }
                return sum;
            }
        };
        //包装一层,为了获取到累加结果
        FutureTask<Integer> task=new FutureTask<>(callable);
        Thread t=new Thread(task);
        t.start();
         //task.get()方法阻塞等待到任务执行结束
        System.out.println(task.get());
    }
}

可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了.

【补充】:

学完Callbale,我们创建线程的方法就又增加了一种

【理解Callable】

Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.

【理解FutureTask】

当我们去吃麻辣烫时,在点完餐后,老板会给我们一个小票(相当于FutureTask),后续我们就可以凭借这张小票来取餐

【相关面试题】:介绍下 Callable 是什么
其实就是上面的理解Callable

二、ReentrantLock类

ReentrantLock:可重入锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.

ReentrantLock 的用法:

  • lock():加锁,获取不到锁就死等
  • unlock():解锁
  • trylock(超出时间):加锁,如果获取不到锁,等待一段时间后就放弃
package juc;

import java.util.concurrent.locks.ReentrantLock;

public class Demo3 {
    public static void main(String[] args) {
        ReentrantLock locker=new ReentrantLock();
        try{
            locker.lock();
          //work
        }finally {
            //防止忘记释放或在work代码中抛出异常无法释放锁
            locker.unlock();
        }
    }
}

【ReentrantLock和synchronized的区别】:

  • synchronized是一个关键字,是在JVM内部实现的(大概率基于C++),ReentrantLock是标准库中的一个类,在JVM外实现(基于Java实现)
  • synchronized不需要手动释放锁,ReentrantLock需要手动释放锁
  • synchronized获取锁失败后会死等,ReentrantLock可以通过trylock的方法等待一段时间后放弃
  • synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造方法传入一个true开启公平锁的模式 在这里插入图片描述
  • 唤醒机制,synchronized是通过Object的wait/notify实现等待-唤醒,每次唤醒的是一个随机等待的线程,ReentrantLock搭配Condition类实现等待-唤醒,可以更精确控制唤醒某个指定的线程

【如何选择使用哪种锁呢?】

  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  • 如果需要使用公平锁, 使用 ReentrantLock.

三、原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

AtomicInteger
AtomicBoolean
AtomicLong
AtomicIntegerArray
AtmoicReference
AtomicStampedReference

AtomicInteger中的常见方法

getAndIncrement() i++
getAndDecrement() i- -
addAndGet(int delta) i+=delta
decrementAndGet() i-=delta
incrementAndGet() ++i

四、线程池

4.1 ExecutorService 和 Executors

ExecutorService是表示一个线程池实例
Executors是一个工厂类,能够创建出几种不同风格的线程池
ExecutorService的submit方法能够向线程池中提交若干任务

【代码示例】:

package juc;

import com.sun.org.apache.bcel.internal.generic.FSUB;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Demo4 {
    public static void main(String[] args) {
        ExecutorService pool= Executors.newFixedThreadPool(10);
        pool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello pool");
            }
        });
    }
}

Executors 本质上是 ThreadPoolExecutor 类的封装

4.2 ThreadPoolExecutor

ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定.

ThreadPoolExecutor的构造方法
在这里插入图片描述
【理解构造方法中的参数】
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.

corePoolSize:核心线程数(正式员工的数量,一旦录用,永不辞退) maximumPoolSize:最大线程数(正式员工 +临时工的数目. 临时工: 一段时间不干活, 就被辞退) keepAliveTime:临时工允许的空闲时间.
unit:时间单位
workQueue:传递任务的阻塞队列(虽然线程池的内部内置了阻塞队列,但我们也能自己定义队列交给线程池使用,这也体现了线程池的可扩展性)
threadFactory:创建线程的工厂, 参与具体的创建线程工作
handler:据决策略,当任务队列满了之后,在尝试添加新任务线程池要怎么做

  • AbortPolicy(): 超过负荷, 直接抛出异常.
  • CallerRunsPolicy(): 调用者负责处理
  • DiscardOldestPolicy(): 丢弃队列中最老的任务.
  • DiscardPolicy(): 丢弃新来的任务

【使用示例】:

package juc;

import java.util.concurrent.*;

public class Demo1 {
    public static void main(String[] args) {
        ExecutorService pool=new ThreadPoolExecutor(1,4,1000,
                TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());
        for(int i=0;i<10;i++){
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello pool");
                }
            });
        }
    }
}

【相关面试题】:使用线程池时,线程数目如何设置?设置成多少比较合适?

只要回答出具体的数字一定都是错的,不同的场景,不同的程序,不同的主机配置都会有差异
正确的做法是压测(性能测试),针对当前的程序进行性能测试,分别设置不同的线程数目,分别进行测试,在测试的过程中会记录程序的时间,CPU占用,内存占用等
根据压测结果,来选择觉得最合适当前场景的数目
程序可以分为两种:

  1. CPU密集型(线程最多也就是CPU核心数,设置在多,也没意义)
  2. IO密集型(线程数可以超过CPU核心数,等待IO的过程不吃CPU)
    实际开发中,一个程序都是既需要CPU,也需要等待IO,这两者的比例就会影响到将线程数设置成多少合适

4.3 线程池工作流程

在这里插入图片描述

五、信号量

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器

【举例理解信号量】

可将信号量理解成停车场的车位显示屏:当前车位100,表示还有100个可用资源
当有车停进车位的时候,相当于申请了一个可用资源,可用车位-1,(相当于信号量的P操作)
当有车开出车位的时候,相当于释放了一个可用资源,可用车位+1,(相当于信号量的V操作)
如果计数器的值为0了,如果还尝试申请资源就会阻塞等待,直到有其他线程释放资源

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
可以将信号量视为更广义的锁,当信号量取值0-1的时候,就退化成了一个普通的锁

【使用示例】

package juc;

import java.util.concurrent.Semaphore;

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        //在构造方法中传入可用资源个数
        Semaphore semaphore=new Semaphore(3);
        //P操作,申请资源
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        //V操作,释放资源
        //semaphore.release();
    }
}
//执行结果:
申请资源
申请资源
申请资源

在代码中,我们连续申请了4次资源,但只有前3次申请资源成功,因为我们设置的可用资源个数为3,只有当有资源释放的时候,第4次申请资源的操作才能成功

六、CountDownLatch

在一个大任务被拆分成那个若干个子任务的时候,用来衡量何时所有子任务的执行结束
【举例理解】

进行一场百米比赛,CountDownLatch 描述的是什么时候所有的选手都到达终点
进行一次大型文件的下载(会被拆分成多个子任务,多线程下载),CountDownLatch描述的是当前所有的线程都下载完毕

【相关面试题】
(1)线程同步的方式有哪些

synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.

(2) 为什么有了 synchronized 还需要 juc 下的 lock?

以 juc 的 ReentrantLock 为例,

  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时 间就放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
  • synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的 线程.
  • ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线 程

(3)AtomicInteger 的实现原理是什么?
基于 CAS 机制. 伪代码如下:

class AtomicInteger {
private int value;
public int getAndIncrement() {
     int oldValue = value;
     while ( CAS(value, oldValue, oldValue+1) != true) {
         oldValue = value;
     }
     return oldValue;
 }
}

(4) 信号量听说过么?之前都用在过哪些场景下?

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器. 使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作

(5) 解释一下 ThreadPoolExecutor 构造方法的参数的含义

参考上面的线程池的ThreadPoolExecutor的介绍

七、线程安全的集合类

之前经常使用的集合类,大部分是线程不安全的,例如:ArrayList、LinkedList、TreeSet、TreeMap、HashMap、HashSet、Queue等
线程安全的集合类有Vector、Stack、HashTable

八、多线程环境使用ArrayList

(1)自己使用同步机制 (synchronized 或者 ReentrantLock)
(2) Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized

CopyOnWriteArrayList

CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器
CopyOnWriteArrayList是线程安全的,只在写的时候会加锁,适用多读少写的场景。主要有两个属性,一个是ReentrantLock,一个是Object[]
CopyOnWriteArrayList内部是以数组存储数据,Object[]就是对应数组的引用。
ReentrantLock为写锁,保证同一时刻只有一个线程在CopyOnWriteArrayList

【源码解析】

  • ReentrantLock 保证对数组进行修改/添加/删除操作都要先加锁
/** The lock protecting all mutators */ 
final transient ReentrantLock lock = new ReentrantLock();
  • volatile修饰数组,保证内存可见性
 private transient volatile Object[] array;
  • get方法:读操作,直接获取元素不需要上锁,效率高
@SuppressWarnings("unchecked")
    private E get(Object[] a, int index) {
        return (E) a[index];
    }

    /**
     * {@inheritDoc}
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E get(int index) {
        return get(getArray(), index);
    }
  • set方法:获取到锁后才能进行set操作,set操作之前需要原数组进行拷贝,对副本进行set,最后让原数组的引用指向副本数组
    /**
     * Replaces the element at the specified position in this list with the
     * specified element.
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        //加锁
        lock.lock();
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                //拷贝出一个副本
                Object[] newElements = Arrays.copyOf(elements, len);
                //对副本进行修改
                newElements[index] = element;
                //让原数组的引用指向副本数组
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }
  • add方法:add数据的时候,会先加锁,然后拷贝旧数组内容到新数组,这里新数组比旧数组长度大1。之后在新数组最后一位放入add的数据,并替换array引用,解锁。
    /**
     * Appends the specified element to the end of this list.
     *
     * @param e element to be appended to this list
     * @return {@code true} (as specified by {@link Collection#add})
     */
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        //加锁
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            //拷贝出一份副本,此时副本的长度比原数组的长度大1
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            //在副本中添加元素
            newElements[len] = e;
            //让原数组的引用指向副本数组
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }
  • remove方法:加锁,创建一个比原数组长度少1的空数组,如果最后一个直接在复制副本数组的时候,复制长度为旧数组的length-1即可,否则略过index下标的元素,将原数组中的剩余元素拷贝到新数组中,最后让原数组的引用指向新数组
    /**
     * Removes the element at the specified position in this list.
     * Shifts any subsequent elements to the left (subtracts one from their
     * indices).  Returns the element that was removed from the list.
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E remove(int index) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            E oldValue = get(elements, index);
            int numMoved = len - index - 1;
            if (numMoved == 0)
                setArray(Arrays.copyOf(elements, len - 1));
            else {
                Object[] newElements = new Object[len - 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index + 1, newElements, index,
                                 numMoved);
                setArray(newElements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

【优点】:在读多写少的场景下, 性能很高
【缺点】:

  1. 内存占用问题。因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。
    针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,ConcurrentHashMap。

  2. 数据一致性问题。CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

九、多线程环境使用队列

(1)ArrayBlockingQueue
基于数组实现的阻塞队列
(2) LinkedBlockingQueue
基于链表实现的阻塞队列
(3) PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
(4) TransferQueue
最多只包含一个元素的阻塞队列

十、多线程环境使用哈希表

HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:

  • Hashtable(不推荐使用)
  • ConcurrentHashMap(推荐使用)

(1)HashTable只是把关键的方法加上了synchronized关键字
相当于直接对HashTable对象本身加锁

  • 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
  • size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
  • 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.
    在这里插入图片描述

(2)ConcurrentHashMap
相比于HashTable,做出了一系列的改进和优化. 以 Java1.8 为例

  • 读操作没有加锁(但是使用了volatile保证从内存读取结果),只对写操作加锁。加锁的方式仍然是用synchronized,但此处并不是一把锁锁整个对象,而是"锁桶"(每个链表的头节点作为锁对象),大大降低了锁冲突的概率
    在这里插入图片描述

  • 充分利用了CAS的特性。比如size属性通过CAS来更新。避免了出现重量级锁的情况

  • 优化了扩容方式: 化整为零 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去. 扩容期间, 新老数组同时存在. 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
    搬完最后一个元素再把老数组删掉. 这个期间, 插入只往新数组加. 这个期间, 查找需要同时查新数组和老数组

【相关面试题】
(1)ConcurrentHashMap的读要加锁吗,为什么?
读操作没有加锁,目的是进一步降低锁冲突的概率,为了保证读到刚修改的数据,搭配了volatile关键字
(2)介绍下ConcurrentHashMap的锁分段技术
锁分段技术是Java1.7中采取的技术,Java1.8中已经不在使用了,简单来说就是把若干个桶分成一段,针对每段加段锁.目的也是为了降低锁冲突的概率,当两个线程访问的数据恰好在同一段上的时候,才会触发锁竞争
(3)ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁,为每个桶都分配一个锁,锁对象是链表的头节点;
将原来数组+链表的实现方式该进成数组+链表/红黑树的方式,当链表较长的时候就转成红黑树
(4)HashTable、HashMap、ConcurrentHashMap之间的区别
HashMap:线程不安全,key值允许为null
HashTable:线程安全,使用synchronized锁整个HashTable对象,效率较低,key不允许为null
ConcurrentHashMap:线程安全,使用synchronized锁每个链表的头节点,锁冲突的概率降低,充分利用CAS机制,优化了扩容方式;key不允许为null

参考资料链接: https://blog.csdn.net/u010723709/article/details/48007881

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值