《Java核心技术面试精讲》学习笔记 Day03

89 阅读14分钟

课程链接:gk.link/a/11URl

十七、一个线程两次调用start()方法会出现什么情况?

17.1 典型回答

Java 的线程是不允许启动两次的,第二次调用必然会抛出 IllegalThreadStateException,这是一种运行时异常,多次调用 start 被认为是编程错误。

关于线程生命周期的不同状态,在 Java 5 以后,线程状态被明确定义在其公共内部枚举类型 java.lang.Thread.State 中,分别是:

  • 新建(NEW),表示线程被创建出来还没真正启动的状态,可以认为它是个 Java 内部状态。

  • 就绪(RUNNABLE),表示该线程已经在 JVM 中执行,当然由于执行需要计算资源,它可能是正在运行,也可能还在等待系统分配给它 CPU 片段,在就绪队列里面排队。在其他一些分析中,会额外区分一种状态 RUNNING,但是从 Java API 的角度,并不能表示出来。

  • 阻塞(BLOCKED),这个状态和我们前面两讲介绍的同步非常相关,阻塞表示线程在等待 Monitor lock。比如,线程试图通过 synchronized 去获取某个锁,但是其他线程已经独占了,那么当前线程就会处于阻塞状态。

  • 等待(WAITING),表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消费者模式,发现任务条件尚未满足,就让当前消费者线程等待(wait),另外的生产者线程去准备任务数据,然后通过类似 notify 等动作,通知消费线程可以继续工作了。Thread.join() 也会令线程进入等待状态。

  • 计时等待(TIMED_WAIT),其进入条件和等待状态类似,但是调用的是存在超时条件的方法,比如 wait 或 join 等方法的指定超时版本。

  • 终止(TERMINATED),不管是意外退出还是正常执行结束,线程已经完成使命,终止运行,也有人把这个状态叫作死亡。在第二次调用 start() 方法的时候,线程可能处于终止或者其他(非 NEW)状态,但是不论如何,都是不可以再次启动的。

17.2 知识扩展

17.2.1 Java线程和操作系统线程

在 Java1.2 之后,Linux中的JVM是基于pthread实现的, 可以直接说 Java 线程就是依赖操作系统实现的,是1:1的关系。

17.2.2 启动线程

www.cnblogs.com/sgh1023/p/1…

17.2.3 线程状态转换

www.cnblogs.com/sgh1023/p/1…

十八、什么情况下Java程序会产生死锁?如何定位、修复?

18.1 典型回答

死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。

定位死锁最常见的方式就是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以在图形界面进行有限的死锁检测。

如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。

18.2 知识扩展——如何在编程中尽量预防死锁?

  • 尽量避免使用多个锁,并且之后需要时候才持有锁。

  • 如果必须使用多个锁,尽量设计好锁的获取顺序。

  • 使用带超时的方法,为程序带来更多的可控性。

  • 静态代码块分析,定位可能得死锁或者竞争情况。

十九、Java并发包提供了哪些并发工具类?

19.1 典型回答

我们通常所说的并发包也就是 java.util.concurrent 及其子包,集中了 Java 并发的各种基础工具类,具体主要包括几个方面:

  • 提供了比 synchronized 更加高级的各种同步结构,包括 CountDownLatch、CyclicBarrier、Semaphore 等,可以实现更加丰富的多线程操作,比如利用 Semaphore 作为资源控制器,限制同时进行工作的线程数量。

  • 各种线程安全的容器,比如最常见的 ConcurrentHashMap、有序的 ConcurrentSkipListMap,或者通过类似快照机制,实现线程安全的动态数组 CopyOnWriteArrayList 等。

  • 各种并发队列实现,如各种 BlockingQueue 实现,比较典型的 ArrayBlockingQueue、 SynchronousQueue 或针对特定场景的 PriorityBlockingQueue 等。

  • 强大的 Executor 框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器。

二十、并发包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么区别?

20.1 典型回答

有时候我们把并发包下面的所有容器都习惯叫作并发容器,但是严格来讲,类似 ConcurrentLinkedQueue 这种“Concurrent*”容器,才是真正代表并发。

关于问题中它们的区别:

  • Concurrent 类型基于 lock-free,在常见的多线程访问场景,一般可以提供较高吞吐量。

  • 而 LinkedBlockingQueue 内部则是基于锁,并提供了 BlockingQueue 的等待性方法。

java.util.concurrent 包提供的容器(Queue、List、Set)、Map,从命名上可以大概区分为 Concurrent*、CopyOnWrite和 Blocking等三类,同样是线程安全容器,可以简单认为:

  • Concurrent 类型没有类似 CopyOnWrite 之类容器相对较重的修改开销。

  • Concurrent 往往提供了较低的遍历一致性。所谓的弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历。

  • 与弱一致性对应的,就是同步容器常见的行为“fail-fast”,也就是检测到容器在遍历过程中发生了修改,则抛出 ConcurrentModificationException,不再继续遍历。

  • 弱一致性的另外一个体现是,size 等操作准确性是有限的,未必是 100% 准确。

  • 与此同时,读取的性能具有一定的不确定性。

二十一、Java并发类库提供的线程池有哪几种? 分别有什么特点?

21.1 典型回答

通常开发者都是利用 Executors 提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的 ExecutorService 类型或者不同的初始参数。Executors 目前提供了 5 种不同的线程池创建配置:

  • newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列。

  • newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads。

  • newSingleThreadExecutor(),它的特点在于工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。

  • newSingleThreadScheduledExecutor() 和 newScheduledThreadPool(int corePoolSize),创建的是个 ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程。

  • newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。

21.2 知识扩展

21.2.1 Executor框架

21.2.2 线程池内部工作过程

  • 工作队列负责存储用户提交的各个任务。

  • 内部的“线程池”,这是指保持工作线程的集合,线程池需要在运行过程中管理线程创建、销毁。例如,对于带缓存的线程池,当任务压力较大时,线程池会创建新的工作线程;当业务压力退去,线程池会在闲置一段时间(默认 60 秒)后结束线程。

  • ThreadFactory 提供上面所需要的创建线程逻辑。

  • 如果任务提交时被拒绝,比如线程池已经处于 SHUTDOWN 状态,需要为其提供处理逻辑,Java 标准库提供了类似ThreadPoolExecutor.AbortPolicy等默认实现,也可以按照实际需求自定义。

21.2.3 线程池参数

  • corePoolSize,所谓的核心线程数,可以大致理解为长期驻留的线程数目(除非设置了 allowCoreThreadTimeOut)。对于不同的线程池,这个值可能会有很大区别,比如 newFixedThreadPool 会将其设置为 nThreads,而对于 newCachedThreadPool 则是为 0。

  • maximumPoolSize,顾名思义,就是线程不够时能够创建的最大线程数。同样进行对比,对于 newFixedThreadPool,当然就是 nThreads,因为其要求是固定大小,而 newCachedThreadPool 则是 Integer.MAX_VALUE。

  • keepAliveTime 和 TimeUnit,这两个参数指定了额外的线程能够闲置多久,显然有些线程池不需要它。

  • workQueue,工作队列,必须是 BlockingQueue。

21.2.4 线程池大小的选择

  • 如果我们的任务主要是进行计算,那么就意味着 CPU 的处理能力是稀缺的资源,我们能够通过大量增加线程数提高计算能力吗?往往是不能的,如果线程太多,反倒可能导致大量的上下文切换开销。所以,这种情况下,通常建议按照 CPU 核的数目 N 或者 N+1。

  • 如果是需要较多等待的任务,例如 I/O 操作比较多,可以参考 Brain Goetz 推荐的计算方法:

线程数 = CPU核数 × 目标CPU利用率 ×(1 + 平均等待时间/平均工作时间)

二十二、AtomicInteger底层实现原理是什么?如何在自己的产品代码中应用CAS操作?

22.1 典型回答

AtomicIntger 是对 int 类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于 CAS(compare-and-swap)技术。

所谓 CAS,表征的是一系列操作的集合,获取当前数值,进行一些运算,利用 CAS 指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。

从 AtomicInteger 的内部属性可以看出,它依赖于 Unsafe 提供的一些底层能力,进行底层操作。

CAS 是 Java 并发中所谓 lock-free 机制的基础。

22.2 知识扩展

22.2.1 ABA 问题

CAS 是在更新时比较前值,如果对方只是恰好相同,例如期间发生了 A -> B -> A 的更新,仅仅判断数值是 A,可能导致不合理的修改操作。针对这种情况,Java 提供了 AtomicStampedReference 工具类,通过为引用建立类似版本号(stamp)的方式,来保证 CAS 的正确性。

22.2.2 AQS

AQS 内部数据和方法,可以简单拆分为:

  • 一个 volatile 的整数成员表征状态,同时提供了 setState 和 getState 方法。

  • 一个先入先出(FIFO)的等待线程队列,以实现多线程间竞争和等待,这是 AQS 机制的核心之一。

  • 各种基于 CAS 的基础操作方法,以及各种期望具体同步结构去实现的 acquire/release 方法。

美团技术团队:tech.meituan.com/2019/12/05/…

二十三、 请介绍类加载过程,什么是双亲委派模型?

23.1 典型回答

一般来说,我们把 Java 的类加载过程分为三个主要步骤:加载、链接、初始化,具体行为在Java 虚拟机规范里有非常详细的定义。

首先是加载阶段(Loading),它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。

加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。

第二阶段是链接(Linking),这是核心的步骤,简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:

  1. 验证(Verification),这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。

  2. 准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令。

  3. 解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。

最后是初始化阶段(initialization),这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。

双亲委派模型简单说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。

23.2 知识扩展

23.2.1 类加载器种类

三种 Oracle JDK 内建的类加载器:

  • 启动类加载器(Bootstrap Class-Loader),加载 jre/lib 下面的 jar 文件,如 rt.jar。它是个超级公民,即使是在开启了 Security Manager 的时候,JDK 仍赋予了它加载的程序 AllPermission。

  • 扩展类加载器(Extension or Ext Class-Loader),负责加载我们放到 jre/lib/ext/ 目录下面的 jar 包,这就是所谓的 extension 机制。

  • 应用类加载器(Application or App Class-Loader),就是加载我们最熟悉的 classpath 的内容。这里有一个容易混淆的概念,系统(System)类加载器,通常来说,其默认就是 JDK 内建的应用类加载器。

23.2.2 双亲委派模型

可见性,子类加载器可以访问父加载器加载的类型,但是反过来是不允许的,不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。

单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见。

二十四、有哪些方法可以在运行时动态生成一个Java类?

24.1 典型回答

常见的 Java 类来源分析,通常的开发过程是,开发者编写 Java 代码,调用 javac 编译成 class 文件,然后通过类加载机制载入 JVM,就成为应用运行时可以使用的 Java 类了。

从上面过程得到启发,其中一个直接的方式是从源码入手,可以利用 Java 程序生成一段源码,然后保存到文件等,下面就只需要解决编译问题了。

有一种笨办法,直接用 ProcessBuilder 之类启动 javac 进程,并指定上面生成的文件作为输入,进行编译。最后,再利用类加载器,在运行时加载即可。

我们能不能直接生成相应的字节码,然后交给类加载器去加载呢?当然也可以,不过直接去写字节码难度太大,通常我们可以利用 Java 字节码操纵工具和类库来实现,比如ASM、Javassist、cglib 等。