乐趣区

关于面试:面试官问在项目中用过多线程吗你就把这个案例讲给他听

在面试当中,有时候会问到 你在我的项目中用过多线程么?

对于一般的应届生或者工作工夫不长的高级开发???—— crud 仔流下了没有技术的眼泪。

博主这里整顿了我的项目中用到了多线程的一个简略的实例,心愿能对你有所启发。

多线程开发实例

利用背景

利用的背景非常简单,博主做的我的项目是一个审核类的我的项目,审核的数据须要推送给第三方监管零碎,这只是一个很简略的对接,然而存在一个问题。

咱们须要推送的数据大略三十万条,然而第三方监管提供的接口只反对单条推送(别问为什么不反对批量,问就是没 过)。能够估算一下,三十万条数据,一条数据按 3 秒算,大略须要 250(为什么恰好会是这个数)个小时。

所以就思考到引入多线程来进行并发操作,升高数据推送的工夫,进步数据推送的实时性。

设计要点

避免反复

咱们推送给第三方的数据必定是不能反复推送的,必须要有一个机制保障各个线程推送数据的隔离。

这里有两个思路:

    1. 将所有数据取到汇合(内存)中,而后进行切割,每个线程推送不同段的数据
    1. 利用 数据库分页的形式,每个线程取 [start,limit] 区间的数据推送,咱们须要保障 start 的一致性

这里采纳了第二种形式,因为思考到可能数据量后续会持续减少,把所有数据都加载到内存中,可能会有比拟大的内存占用。

失败机制

咱们还得思考到线程推送数据失败的状况。

如果是本人的零碎,咱们能够把多线程调用的办法抽出来加一个事务,一个线程异样,整体回滚。

然而是和第三方的对接,咱们都没法做事务的,所以,咱们采纳了间接在数据库记录失败状态的办法,能够在前面用其它形式解决失败的数据。

线程池抉择

在理论应用中,咱们必定是要用到线程池来治理线程,对于线程池,咱们罕用 ThreadPoolExecutor 提供的线程池服务,SpringBoot 中同样也提供了线程池异步的形式,尽管 SprignBoot 异步可能更不便一点,然而应用 ThreadPoolExecutor 更加直观地控制线程池,所以咱们间接应用 ThreadPoolExecutor 构造方法创立线程池。

大略的技术设计示意图:

外围代码

下面叭叭了一堆,到了 show you code 的环节了。我将我的项目里的代码抽取进去,简化出了一个示例。

外围代码如下:

/**
 * @Author 三分恶
 * @Date 2021/3/5
 * @Description
 */
@Service
public class PushProcessServiceImpl implements PushProcessService {
    @Autowired
    private PushUtil pushUtil;
    @Autowired
    private PushProcessMapper pushProcessMapper;

    private final static Logger logger = LoggerFactory.getLogger(PushProcessServiceImpl.class);

    // 每个线程每次查问的条数
    private static final Integer LIMIT = 5000;
    // 起的线程数
    private static final Integer THREAD_NUM = 5;
    // 创立线程池
    ThreadPoolExecutor pool = new ThreadPoolExecutor(THREAD_NUM, THREAD_NUM * 2, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));

    @Override
    public void pushData() throws ExecutionException, InterruptedException {
        // 计数器,须要保障线程平安
        int count = 0;
        // 未推送数据总数
        Integer total = pushProcessMapper.countPushRecordsByState(0);
        logger.info("未推送数据条数:{}", total);
        // 计算须要多少轮
        int num = total / (LIMIT * THREAD_NUM) + 1;
        logger.info("要通过的轮数:{}", num);
        // 统计总共推送胜利的数据条数
        int totalSuccessCount = 0;
        for (int i = 0; i < num; i++) {
            // 接管线程返回后果
            List<Future<Integer>> futureList = new ArrayList<>(32);
            // 起 THREAD_NUM 个线程并行查问更新库,加锁
            for (int j = 0; j < THREAD_NUM; j++) {synchronized (PushProcessServiceImpl.class) {
                    int start = count * LIMIT;
                    count++;
                    // 提交线程,用数据起始地位标识线程
                    Future<Integer> future = pool.submit(new PushDataTask(start, LIMIT, start));
                    // 先不取值,避免阻塞, 放进汇合
                    futureList.add(future);
                }
            }
            // 统计本轮推送胜利数据
            for (Future f : futureList) {totalSuccessCount = totalSuccessCount + (int) f.get();}
        }
        // 更新推送标记
        pushProcessMapper.updateAllState(1);
        logger.info("推送数据实现,需推送数据:{}, 推送胜利:{}", total, totalSuccessCount);
    }

    /**
     * 推送数据线程类
     */
    class PushDataTask implements Callable<Integer> {
        int start;
        int limit;
        int threadNo;   // 线程编号

        PushDataTask(int start, int limit, int threadNo) {
            this.start = start;
            this.limit = limit;
            this.threadNo = threadNo;
        }

        @Override
        public Integer call() throws Exception {
            int count = 0;
            // 推送的数据
            List<PushProcess> pushProcessList = pushProcessMapper.findPushRecordsByStateLimit(0, start, limit);
            if (CollectionUtils.isEmpty(pushProcessList)) {return count;}
            logger.info("线程 {} 开始推送数据", threadNo);
            for (PushProcess process : pushProcessList) {boolean isSuccess = pushUtil.sendRecord(process);
                if (isSuccess) {   // 推送胜利
                    // 更新推送标识
                    pushProcessMapper.updateFlagById(process.getId(), 1);
                    count++;
                } else {  // 推送失败
                    pushProcessMapper.updateFlagById(process.getId(), 2);
                }
            }
            logger.info("线程 {} 推送胜利 {} 条", threadNo, count);
            return count;
        }
    }
}

代码很长,咱们简略说一下要害的中央:

  • 线程创立:线程外部类抉择了实现 Callable 接口,这样不便获取线程工作执行的后果,在示例里用于统计线程推送胜利的数量
 class PushDataTask implements Callable<Integer> {
  • 应用 ThreadPoolExecutor 创立线程池,
  // 创立线程池
      ThreadPoolExecutor pool = new ThreadPoolExecutor(THREAD_NUM, THREAD_NUM * 2, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));

次要结构参数如下:

​ – corePoolSize:线程外围参数抉择了 5

​ – maximumPoolSize:最大线程数抉择了外围线程数 2 倍数

​ – keepAliveTime:非核心闲置线程存活工夫间接置为 0

​ – unit:非核心线程放弃存活的工夫抉择了 TimeUnit.SECONDS 秒

​ – workQueue:线程池期待队列,应用 容量初始为 100 的 LinkedBlockingQueue 阻塞队列

这里还有没写进去的线程池回绝策略,采纳了默认 AbortPolicy:间接抛弃工作,抛出异样。

  • 应用 synchronized 来保障线程平安,保障计数器的减少是有序的
  synchronized (PushProcessServiceImpl.class) {
  • 应用汇合来接管线程的运行后果,避免阻塞
List<Future<Integer>> futureList = new ArrayList<>(32);

好了,次要的代码和简略的解析就到这里了。

对于这个简略的 demo,这里只是简略地做推送数据处理。考虑一下,这个实例是不是能够用在你我的项目的某些中央。例如监管零碎的数据校验、审计零碎的数据统计、电商零碎的数据分析等等,只有是有大量数据处理的中央,都能够把这个例子联合到你的我的项目里,这样你就有了多线程开发的教训。

残缺代码仓库地址在文章底部????????

对线面试官

  • 面试官:小伙子,不错,你这个整挺好。
  • 老三:那是天然。
  • 面试官:呦,小伙子,挺自信,那我得好好考考你。
  • 老三:放马过来,但考不妨。

面试官:先从最简略的开始,说说什么是线程吧

要说线程,必先说过程。

过程是程序的⼀次执⾏过程,是零碎运⾏程序的根本单位,因而过程是动静的。零碎运⾏⼀个程序即是⼀个过程从创立,运⾏到沦亡的过程。

线程与过程类似,但线程是⼀个⽐过程更⼩的执⾏单位。⼀个过程在其执⾏的过程中能够产⽣多个线程。与过程不同的是同类的多个线程共享过程的堆和⽅法区资源,但每个线程有⾃⼰的程序计数器、虚拟机栈和本地⽅法栈,所以零碎在产⽣⼀个线程,或是在各个线程之间作切换⼯作时,累赘要⽐过程⼩得多,也正因为如此,线程也被称为轻量级过程。

面试官:说说 Java 里怎么创立线程吧

Java 里创立线程次要有三种形式:

  • 继承 Thread 类:Thread 类实质上是实现了 Runnable 接口的一个实例,代表一个线程的实例。启动线程的惟一办法就是通过 Thread 类的 start()实例办法。start()办法是一个 native 办法,它将启动一个新线程,并执行 run()办法。
  • 实现 Runnable 接口:如果本人的类曾经 extends 另一个类,就无奈间接 extends Thread,此时,能够实现一个 Runnable 接口。
  • 实现 Callable 接口:实现 Callable 接口,重写 call()办法,能够返回一个 Future 类型的返回值。我在下面的例子里就是用到了这种形式。

面试官:说说线程的生命周期和状态

在 Java 中,线程共有六种状态:

状态 阐明
NEW 初始状态:线程被创立,但还没有调用 start()办法
RUNNABLE 运行状态:Java 线程将操作系统中的就绪和运行两种状态抽象的称作“运行”
BLOCKED 阻塞状态:示意线程阻塞于锁
WAITING 期待状态:示意线程进入期待状态,进入该状态示意以后线程须要期待其余线程做出一些特定动作(告诉或中断)
TIME_WAITING 超时期待状态:该状态不同于 WAITIND,它是能够在指定的工夫自行返回的
TERMINATED 终止状态:示意以后线程曾经执行结束

线程在本身的生命周期中,并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java 线程状态变动如图示:

面试官:我看你提到了线程阻塞,那你再说说线程死锁吧

线程死锁形容的是这样⼀种状况:多个线程同时被阻塞,它们中的⼀个或者全副都在期待某个资源被开释。因为线程被⽆限期地阻塞,因而程序不可能失常终⽌。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对⽅的资源,所以这两个线程就会相互期待⽽进⼊死锁状态。

产生死锁必须满足四个条件:

  1. 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
  2. 申请与放弃条件:⼀个过程因申请资源⽽阻塞时,对已取得的资源放弃不放。
  3. 不剥夺条件: 线程已取得的资源在末使⽤完之前不能被其余线程强⾏剥夺,只有⾃⼰使⽤结束后才开释资源。
  4. 循环期待条件: 若⼲过程之间造成⼀种头尾相接的循环期待资源关系。

面试官:怎么防止死锁呢?

我上⾯说了产⽣死锁的四个必要条件,为了防止死锁,咱们只有毁坏产⽣死锁的四个条件中的其中⼀个就能够了。

  1. 毁坏互斥条件:这个条件咱们没有方法毁坏,因为咱们⽤锁原本就是想让他们互斥的(临界资源须要互斥拜访)。
  2. 毁坏申请与放弃条件:⼀次性申请所有的资源。
  3. 毁坏不剥夺条件:占⽤局部资源的线程进⼀步申请其余资源时,如果申请不到,能够被动开释它占有的资源。
  4. 毁坏循环期待条件:靠按序申请资源来预防。按某⼀程序申请资源,开释资源则反序开释。毁坏循环期待条件。

面试官:我看你的例子里用到了 synchronized,说说 synchronized 的用法吧

synchronized 关键字最次要的三种使⽤⽅式:

1.润饰实例⽅法: 作⽤于以后对象实例加锁,进⼊同步代码前要取得 以后对象实例的锁

synchronized void method() {// 业务代码}

2.润饰动态⽅法: 也就是给以后类加锁,会作⽤于类的所有对象实例,进⼊同步代码前要取得以后 class 的锁。因为动态成员不属于任何⼀个实例对象,是类成员(static 表明这是该类的⼀个动态资源,不论 new 了多少个对象,只有⼀份)。所以,如果⼀个线程 A 调⽤⼀个实例对象的⾮动态 synchronized ⽅法,⽽线程 B 须要调⽤这个实例对象所属类的动态 synchronized ⽅法,是容许的,不会发⽣互斥景象,因为拜访动态 synchronized ⽅法占⽤的锁是以后类的锁,⽽拜访⾮动态 synchronized ⽅法占⽤的锁是以后实例对象锁。

synchronized void staic method() {// 业务代码}

3.润饰代码块:指定加锁对象,对给定对象 / 类加锁。synchronized(this|object) 示意进⼊同步代码库前要取得给定对象的锁。synchronized(类.class) 示意进⼊同步代码前要取得 以后 class 的锁

synchronized(this) {// 业务代码}

在我的例子里应用 synchronized 润饰代码块,给 PushProcessServiceImpl 类加锁,进⼊同步代码前要取得 以后 class 的锁,避免 PushProcessServiceImpl 类的对象在管制层调用推送数据的办法。

面试官:除了应用 synchronized,还有什么方法来加锁吗?具体说一下

能够应用 juc 包提供的锁。Lock 接口次要相干的类和接口如下。

Lock 中的次要办法:

  • lock:用来获取锁,如果锁被其余线程获取,进入期待状态。
  • lockInterruptibly:通过这个办法去获取锁时,如果线程正在期待获取锁,则这个线程可能响应中断,即中断线程的期待状态。
  • tryLock:tryLock 办法是有返回值的,它示意用来尝试获取锁,如果获取胜利,则返回 true,如果获取失败(即锁已被其余线程获取),则返回 false。
  • tryLock(long,TimeUnit):与 tryLock 相似,只不过是有等待时间,在等待时间内获取到锁返回 true,超时返回 false。
  • unlock:开释锁。

其它接口和类:

  • ReetrantLock(可重入锁):实现了 Lock 接口,可重入锁,外部定义了偏心锁与非偏心锁。能够实现 synchronized 所能实现的所有工作。
  • ReadWriteLock(读写锁):
public interface ReadWriteLock {Lock readLock();       // 获取读锁  
    Lock writeLock();      // 获取写锁}  

一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作离开,分成 2 个锁来调配给线程,从而使得多个线程能够同时进行读操作。

  • ReetrantReadWriteLock(可重入读写锁):ReetrantReadWriteLock 同样反对公平性抉择,反对重进入,锁降级。

面试官:说说 synchronized 和 Lock 的区别

类别 synchronized Lock
存在档次 Java 的关键字,在 jvm 层面上 是一个接口,api 级别
锁的开释 1、以获取锁的线程执行完同步代码,开释锁 2、线程执行产生异样,jvm 会让线程开释锁 在 finally 中必须开释锁,不然容易造成线程死锁
锁的获取 假如 A 线程取得锁,B 线程期待。如果 A 线程阻塞,B 线程会始终期待 分状况而定,Lock 有多个锁获取的形式,具体上面会说道,大抵就是能够尝试取得锁,线程能够不必始终期待
锁状态 无奈判断 能够判断
锁类型 可重入 不可中断 非偏心 可重入 可判断 可偏心(两者皆可)
性能 大量同步 大量同步

面试官:你提到了 synchronized 基于 jvm 层面,对这个有理解吗?

synchronized 是利用 java 提供的原⼦性内置锁(monitor 对象),每个对象中都内置了⼀个 ObjectMonitor 对象。这种内置的并且使⽤者看不到的锁也被称为监视器锁。

<big> 同步语句块 </big>

synchronized 同步语句块的实现使⽤的是 monitorentermonitorexit 指令,其中monitorenter 指令指向同步代码块的开始地位monitorexit 指令则指明同步代码块的完结地位。

执⾏ monitorenter 指令时会尝试获取内置锁,如果对象没有被锁定或者曾经取得了锁,锁的计数器 +1。此时其余竞争锁的线程则会进⼊期待队列中。

执⾏ monitorexit 指令时则会把计数器 -1,当计数器值为 0 时,则锁开释,处于期待队列中的线程再持续竞争锁。

<big> synchronized 润饰⽅法</big>

synchronized 润饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,获得代之的的确是 ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法。JVM 通过该 ACC_SYNCHRONIZED 拜访标记来分别⼀个⽅法是否申明为同步⽅法,从⽽执⾏相应的同步调⽤。

当然,二者细节略有不同,但实质上都是获取原子性内置锁。

再深刻一点,synchronized 实际上有两个队列 waitSet 和 entryList。

  1. 当多个线程进⼊同步代码块时,⾸先进⼊ entryList
  2. 有⼀个线程获取到 monitor 锁后,就赋值给以后线程,并且计数器 +1
  3. 如果线程调⽤ wait ⽅法,将开释锁,以后线程置为 null,计数器 -1,同时进⼊ waitSet 期待被唤醒,调⽤ notify 或者 notifyAll 之后⼜会进⼊ entryList 竞争锁
  4. 如果线程执⾏结束,同样开释锁,计数器 -1,以后线程置为 null

synchronized 的优化能说一说吗?

从 JDK1.6 版本之后,synchronized 自身也在一直优化锁的机制,有些状况下他并不会是⼀个很重量级的锁。优化机制包含⾃适应锁、⾃旋锁、锁打消、锁粗化、偏差锁、轻量级锁。

锁的状态从低到⾼顺次为⽆锁 -> 偏差锁 -> 轻量级锁 -> 重量级锁,降级的过程就是从低到⾼。

自旋锁:因为⼤局部时候,锁被占⽤的工夫很短,共享变量的锁定工夫也很短,所有没有必要挂起线程,⽤户态和内核态的来回高低⽂切换重大影响性能。⾃旋的概念就是让线程执⾏⼀个忙循环,能够了解为就是啥也不⼲,防⽌从⽤户态转⼊内核态,⾃旋锁能够通过设置 -XX:+UseSpining 来开启,⾃旋的默认次数是 10 次,能够使⽤ -XX:PreBlockSpin 设置。

自适应锁:自适应锁就是自适应的自旋锁,自旋锁的工夫不是固定工夫,而是由前⼀次在同⼀个锁上的⾃旋工夫和锁的持有者状态来决定。

锁打消:锁打消指的是 JVM 检测到⼀些同步的代码块,齐全不存在数据竞争的场景,也就是不须要加锁,就会进⾏锁打消。

锁粗化:锁粗化指的是有很多操作都是对同⼀个对象进⾏加锁,就会把锁的同步范畴扩大到整个操作序列之外。

偏差锁:当线程拜访同步块获取锁时,会在对象头和栈帧中的锁记录⾥存储偏差锁的线程 ID,之后这个线程再次进⼊同步块时都不须要 CAS 来加锁和解锁了,偏差锁会永远偏差第⼀个取得锁的线程,如果后续没有其余线程取得过这个锁,持有锁的线程就永远不须要进⾏同步,反之,当有其余线程竞争偏差锁时,持有偏差锁的线程就会开释偏差锁。能够⽤过设置 -XX:+UseBiasedLocking 开启偏差锁。

轻量级锁:JVM 的对象的对象头中蕴含有⼀些锁的标记位,代码进⼊同步块的时候,JVM 将会使⽤ CAS ⽅式来尝试获取锁,如果更新胜利则会把对象头中的状态位标记为轻量级锁,如果更新失败,以后线程就尝试⾃旋来取得锁。

锁降级的过程非常复杂,简略点说,偏差锁就是通过对象头的偏差线程 ID 来对⽐,甚⾄都不须要 CAS 了,⽽轻量级锁次要就是通过 CAS 批改对象头锁记录和⾃旋来实现,重量级锁则是除了领有锁的线程其余全副阻塞。

面试官:说一下 CAS

CAS(Compare And Swap/Set)比拟并替换,CAS 算法的过程是这样:它蕴含 3 个参数 CAS(V,E,N)。V 示意要更新的变量(内存值),E 示意预期值(旧的),N 示意新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则阐明曾经有其余线程做了更新,则以后线程什么都不做。最初,CAS 返回以后 V 的实在值。

CAS 是一种乐观锁,它总是认为本人能够胜利实现操作。当多个线程同时应用 CAS 操作一个变量时,只有一个会胜出,并胜利更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且容许再次尝试,当然也容许失败的线程放弃操作。基于这样的原理,CAS 操作即便没有锁,也能够发现其余线程对以后线程的烦扰,并进行失当的解决。

java.util.concurrent.atomic 包下的类大多是应用 CAS 操作来实现的 (AtomicInteger,AtomicBoolean,AtomicLong)。

面试官:CAS 会导致什么问题?

  1. ABA 问题:

比如说一个线程 one 从内存地位 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,而后 two 又将 V 地位的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中依然是 A,而后 one 操作胜利。只管线程 one 的 CAS 操作胜利,但可能存在潜藏的问题。从 Java1.5 开始 JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。

  1. 循环工夫长开销大:

对于资源竞争重大(线程抵触重大)的状况,CAS 自旋的概率会比拟大,从而节约更多的 CPU 资源,效率低于 synchronized。

  1. 只能保障一个共享变量的原子操作:

当对一个共享变量执行操作时,咱们能够应用循环 CAS 的形式来保障原子操作,然而对多个共享变量操作时,循环 CAS 就无奈保障操作的原子性,这个时候就能够用锁。

面试官:能说一下说下 ReentrantLock 原理吗

ReentrantLock 是基于 Lock 实现的可重入锁,所有的 Lock 都是基于 AQS 实现的,AQS 和 Condition 各自保护不同的对象,在应用 Lock 和 Condition 时,其实就是两个队列的相互挪动。它所提供的共享锁、互斥锁都是基于对 state 的操作。

面试官:能说一下 AQS 吗

AbstractQueuedSynchronizer,形象的队列式的同步器,AQS 定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如罕用的

ReentrantLock/Semaphore/CountDownLatch。

AQS 核⼼思维是,如果被申请的共享资源闲暇,则将以后申请资源的线程设置为无效的⼯作线程,并且将共享资源设置为锁定状态。如果被申请的共享资源被占⽤,那么就须要⼀套线程阻塞期待以及被唤醒时锁调配的机制,这个机制 AQS 是⽤ CLH 队列锁实现的,行将临时获取不到锁的线程加⼊到队列中。

看个 AQS 原理图:

AQS 使⽤⼀个 int 成员变量来示意同步状态,通过内置的 FIFO 队列来实现获取资源线程的排队⼯作。AQS 使⽤ CAS 对该同步状态进⾏原⼦操作实现对其值的批改。

private volatile int state;// 共享变量,使⽤ volatile 润饰保障线程可⻅性

状态信息通过 protected 类型的 getState,setState,compareAndSetState 进⾏操作

// 返回同步状态的以后值
protected final int getState() {return state;}
// 设置同步状态的值
protected final void setState(int newState) {state = newState;}
// 原⼦地(CAS 操作)将同步状态值设置为给定值 update 如果以后同步状态的值等于 expect(期望值)protected final boolean compareAndSetState(int expect, int update) {return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

尝试加锁的时候通过 CAS(CompareAndSwap)批改值,如果胜利设置为 1,并且把以后线程 ID 赋值,则代表加锁胜利,⼀旦获取到锁,其余的线程将会被阻塞进⼊阻塞队列⾃旋,取得锁的线程开释锁的时候将会唤醒阻塞队列中的线程,开释锁的时候则会把 state 从新置为 0,同时以后线程 ID 置为空。

面试官:能说一下 Semaphore/CountDownLatch/CyclicBarrier 吗

  • Semaphore(信号量)- 容许多个线程同时拜访:synchronized 和 ReentrantLock 都是一次只容许一个线程拜访某个资源,Semaphore(信号量)能够指定多个线程同时拜访某个资源。
  • CountDownLatch(倒计时器):CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程期待,它能够让某一个线程期待直到倒计时完结,再开始执行。
  • CyclicBarrier(循环栅栏):CyclicBarrier 和 CountDownLatch 十分相似,它也能够实现线程间的技术期待,然而它的性能比 CountDownLatch 更加简单和弱小。次要利用场景和 CountDownLatch 相似。CyclicBarrier 的字面意思是可循环应用(Cyclic)的屏障(Barrier)。它要做的事件是,让一组线程达到一个屏障(也能够叫同步点)时被阻塞,直到最初一个线程达到屏障时,屏障才会开门,所有被屏障拦挡的线程才会持续干活。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数示意屏障拦挡的线程数量,每个线程调用 await()办法通知 CyclicBarrier 我曾经达到了屏障,而后以后线程被阻塞。

volatile 原理晓得吗?

相⽐ synchronized 的加锁⽅式来解决共享变量的内存可⻅性问题,volatile 就是更轻量的抉择,他没有高低⽂切换的额定开销老本。使⽤ volatile 申明的变量,能够确保值被更新的时候对其余线程⽴刻可⻅。

volatile 使⽤ 内存屏障 来保障不会发⽣指令重排,解决了内存可⻅性的问题。

咱们晓得,线程都是从主内存中读取共享变量到⼯作内存来操作,实现之后再把后果写会主内存,然而这样就会带来可⻅性问题。举个例⼦,假如当初咱们是两级缓存的双核 CPU 架构,蕴含 L1、L2 两级缓存。

那么,如果 X 变量⽤ volatile 润饰的话,当线程 A 再次读取变量 X 的话,CPU 就会依据缓存⼀致性协定强制线程 A 从新从主内存加载最新的值到⾃⼰的⼯作内存,⽽不是间接⽤缓存中的值。

再来说 内存屏障 的问题,volatile 润饰之后会加⼊不同的内存屏障来保障可⻅性的问题能正确执⾏。这⾥写的屏障基于书中提供的内容,然而实际上因为 CPU 架构不同,重排序的策略不同,提供的内存屏障也不⼀样,⽐如 x86 平台上,只有 StoreLoad ⼀种内存屏障。

  1. StoreStore 屏障,保障上⾯的一般写不和 volatile 写发⽣重排序
  2. StoreLoad 屏障,保障 volatile 写与后⾯可能的 volatile 读写不发⽣重排序
  3. LoadLoad 屏障,禁⽌ volatile 读与后⾯的一般读重排序
  4. LoadStore 屏障,禁⽌ volatile 读和后⾯的一般写重排序

面试官:说说你对 Java 内存模型(JMM)的了解,为什么要用 JMM

自身随着 CPU 和内存的倒退速度差别的问题,导致 CPU 的速度远快于内存,所以当初的 CPU 加⼊了⾼速缓存,⾼速缓存⼀般能够分为 L1、L2、L3 三级缓存。基于上⾯的例⼦咱们晓得了这导致了缓存⼀致性的问题,所以加⼊了缓存⼀致性协定,同时导致了内存可⻅性的问题,⽽编译器和 CPU 的重排序导致了原⼦性和有序性的问题,JMM 内存模型正是对多线程操作下的⼀系列标准束缚,通过 JMM 咱们才屏蔽了不同硬件和操作系统内存的拜访差别,这样保障了 Java 程序在不同的平台下达到⼀致的内存拜访成果,同时也是保障在⾼效并发的时候程序可能正确执⾏。

面试官:看你用到了线程池,能说说为什么吗

  1. 进步线程的利用率,升高资源的耗费。
  2. 进步响应速度,线程的创立工夫为 T1,执行工夫 T2,销毁工夫 T3,用线程池能够免去 T1 和 T3 的工夫。
  3. 便于对立治理线程对象
  4. 可管制最大并发数

面试官:能说一下线程池的外围参数吗?

来看一 ThreadPoolExecutor 的构造方法:

public ThreadPoolExecutor(int corePoolSize,
                         int maximumPoolSize,
                         long keepAliveTime,
                         TimeUnit unit,
                        BlockingQueue<Runnable> workQueue,
                        ThreadFactory threadFactory,
                        RejectedExecutionHandler handler) 
  • 核⼼线程数 corePoolSize : 此值是用来初始化线程池中外围线程数,当线程池中线程池数 < corePoolSize时,零碎默认是增加一个工作才创立一个线程池。能够通过调用 prestartAllCoreThreads 办法一次性的启动 corePoolSize 个数的线程。当线程数 = corePoolSize 时,新工作会追加到 workQueue 中。
  • 容许的最大线程数 maximumPoolSize:maximumPoolSize示意容许的最大线程数 = (非核心线程数 + 外围线程数),当 BlockingQueue 也满了,但线程池中总线程数 < maximumPoolSize时候就会再次创立新的线程。
  • 沉闷工夫 keepAliveTime:非核心线程 =(maximumPoolSize – corePoolSize) , 非核心线程闲置下来不干活最多存活工夫。
  • 放弃存活工夫 unit:线程池中非核心线程放弃存活的工夫
  • 期待队列 workQueue:线程池 期待队列,保护着期待执行的 Runnable 对象。当运行当线程数 = corePoolSize 时,新的工作会被增加到 workQueue 中,如果 workQueue 也满了则尝试用非核心线程执行工作
  • 线程工厂 threadFactory:创立一个新线程时应用的工厂,能够用来设定线程名、是否为 daemon 线程等等。
  • 回绝策略 RejectedExecutionHandler:corePoolSizeworkQueuemaximumPoolSize都不可用的时候执行的 饱和策略。

面试官:残缺说一下线程池的工作流程

  1. 线程池刚创立时,外面没有一个线程。工作队列是作为参数传进来的。不过,就算队列外面有工作,线程池也不会马上执行它们。
  2. 当调用 execute() 办法增加一个工作时,线程池会做如下判断:
  • a) 如果正在运行的线程数量小于 corePoolSize,那么马上创立线程运行这个工作;
  • b) 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个工作放入队列;
  • c) 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创立非核心线程立即运行这个工作;
  • d) 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会依据回绝策略来对应解决。
  1. 当一个线程实现工作时,它会从队列中取下一个工作来执行。
  2. 当一个线程无事可做,超过肯定的工夫(keepAliveTime)时,线程池会判断,如果以后运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有工作实现后,它最终会膨胀到 corePoolSize 的大小。

面试官:回绝策略有哪些

次要有 4 种回绝策略:

  1. AbortPolicy:间接抛弃工作,抛出异样,这是默认策略
  2. CallerRunsPolicy:只⽤调⽤者所在的线程来解决工作
  3. DiscardOldestPolicy:抛弃期待队列中最旧的工作,并执⾏当前任务
  4. DiscardPolicy:间接抛弃工作,也不抛出异样

面试官:说一下你的外围线程数是怎么选的

线程在 Java 中属于稀缺资源,线程池不是越大越好也不是越小越好。工作分为计算密集型、IO 密集型、混合型。

  1. 计算密集型个别举荐线程池不要过大,个别是 CPU 数 + 1,+ 1 是因为可能存在 页缺失(就是可能存在有些数据在硬盘中须要多来一个线程将数据读入内存)。如果线程池数太大,可能会频繁的 进行线程上下文切换跟任务调度。取得以后 CPU 外围数代码如下:
Runtime.getRuntime().availableProcessors();
  1. IO 密集型:线程数适当大一点,机器的 Cpu 外围数 *2。
  2. 混合型:如果密集型站大头则拆分的必要性不大,如果 IO 型占据不少有必要,Mark 下。

面试官:说一下有哪些常见阻塞队列

  1. ArrayBlockingQueue:由数组构造组成的有界阻塞队列。
  2. LinkedBlockingQueue:由链表构造组成的有界阻塞队列。
  3. PriorityBlockingQueue:反对优先级排序的无界阻塞队列。
  4. DelayQueue:应用优先级队列实现的无界阻塞队列。
  5. SynchronousQueue:不存储元素的阻塞队列。
  6. LinkedTransferQueue:由链表构造组成的无界阻塞队列。
  7. LinkedBlockingDeque:由链表构造组成的双向阻塞队列

面试官:说一下有哪几种常见的线程池吧

在下面咱们间接用到了 ThreadPoolExecutor 的构造方法创立线程池,还有另一种形式,通过 Executors 创立线程。

须要留神的是,阿里巴巴 Java 开发手册强制禁止应用 Executors 创立线程

比拟典型常见的四种线程池包含:newFixedThreadPool newSingleThreadExecutornewCachedThreadPool

newScheduledThreadPool

FixedThreadPool

  1. 定长的线程池,有外围线程,外围线程的即为最大的线程数量,没有非核心线程。
  2. 应用的 无界 的期待队列是LinkedBlockingQueue。应用时候有堵满期待队列的危险。

SingleThreadPool

只有一条线程来执行工作,实用于有程序的工作的利用场景,也是用的 界期待队列

CachedThreadPool

可缓存的线程池,该线程池中没有外围线程,非核心线程的数量为 Integer.max_value,就是无限大,当有须要时创立线程来执行工作,没有须要时回收线程,实用于耗时少,任务量大的状况。工作队列用的是 SynchronousQueue 如果生产多快生产慢,则会导致创立很多线程需注意。

ScheduledThreadPoolExecutor

周期性 执行工作的线程池,依照某种特定的打算执行线程中的工作,有外围线程,但也有非核心线程,非核心线程的大小也为无限大。实用于执行周期性的工作。

看构造函数:调用的还是 ThreadPoolExecutor 构造函数,区别不同点在于工作队列是用的 DelayedWorkQueue。


  • 面试官:这些题都能答复进去,很好,小伙子,很有精力!
  • 老三:谢谢。那面试官老师,你看这一轮面试……
  • 面试官:尽管你答的很好,但你的我的项目数据量只有十万级,不合乎咱们的要求。所以,面试不能让你过。

老三下来就是一个左刺拳,再接一个右正蹬……

  • 面试官:啊……年轻人不讲武德,来偷袭……

<big>代码地址:https://gitee.com/fighter3/th…</big>

好了,通过本文,置信你对多线程的利用和原理都有了肯定的理解。文章结尾提到的 crud 仔就是博主自己了,技术水平无限,不免错漏,欢送指出,谢谢!

<big>参考:</big>

【1】:应用多线程查问百万条用户数据将汉字转化成拼音

【2】:讲真 这次相对让你轻松学习线程池

【3】:SpringBoot 学习笔记(十七:异步调用)

【4】:JavaGuide 编著《JavaGuide 面试突击版》

【5】:艾小仙编著《我想进大厂面试总结》

【6】:佚名编著《Java 外围知识点整顿》

【7】:Java 并发基础知识,我用思维导图整顿好了

【8】:并发编程的锁机制:synchronized 和 lock

【9】:详解 synchronized 与 Lock 的区别与应用

【10】:bugstack 小傅哥编著《Java 面经手册》

退出移动版