2020 Java相关面试题整理

----点击查看更多2020面试题系列文章----

JVM运行时数据区

线程共享部分:方法区、堆内存

线程独占部分:虚拟机栈、本地方法栈、程序计数器

方法区

用来存储加载的类信息、常量、静态变量、编译后的代码等数据

堆内存

用来存放对象的区域,可以细分为:老年代、新生代(Eden、From Survivor、To Survivor)

虚拟机栈

每个线程都在这个空间有一个私有的空间,线程栈由多个栈帧组成。一个线程会执行一个或者多个方法,一个方法对应一个栈帧。

栈帧内容包括:局部变量表、操作数栈、动态链接、方法返回地址、附加信息等,栈内存默认最大是1M。

本地方法栈

和虚拟机栈功能类似,虚拟机栈是为虚拟机执行JAVA方法而准备的,本地方法栈是为虚拟机使用Native本地方法而准备的

程序计数器

记录当前线程执行字节码的位置,存储的是字节码指令地址,如果执行Native方法,则计数器值为空。

CPU同一时间,只会执行一条线程中的指令。JVM多线程会轮流切换并分配CPU执行时间,为了线程切换后,需要通过程序计数器来恢复正确的执行位置。

Java类加载过程

过程包括加载、验证、准备、解析、初始化

加载是类加载的第一个过程,在这个阶段,将完成一下三件事情:

  • 通过类的全限定名获取类的二进制流

  • 将该二进制流中的静态存储结构转化为方法区运行时数据结构

  • 内存中生成该类的 java.lang.Class 对象,作为该类的数据访问入口。

验证的目的是为了确保 Class 文件的字节流中的信息不回危害到虚拟机.在该阶段主要完成以下四钟验证:

  • 文件格式验证:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型。

  • 元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。

  • 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。

  • 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。

准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。

解析阶段主要完成符号引用到直接引用的转换动作。解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。

初始化是类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。

描述一下JVM加载class文件的原理机制?

JVM中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java中的类加载器是一个重要的Java运行时系统组件,它负责在运行时查找和装入类文件中的类。

由于Java的跨平台性,经过编译的Java源程序并不是一个可执行程序,而是一个或多个类文件。当Java程序需要使用某个类时,JVM会确保这个类已经被加载、连接(验证、准备和解析)和初始化。

类的加载是指把类的.class文件中的数据读入到内存中,通常是创建一个字节数组读入.class文件,然后产生与所加载类对应的Class对象。加载完成后,Class对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。

最后JVM对类进行初始化,包括:

1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;

2)如果类中存在初始化语句,就依次执行这些初始化语句。

类的加载是由类加载器完成的,类加载器包括:

启动类加载器(Bootstrap ClassLoader)、

扩展类加载器(Extension ClassLoader)、

应用程序类加载器(Application ClassLoader)、

用户自定义类加载器(java.lang.ClassLoader的子类)。

类加载过程采取了双亲委派模型机制(PDM)。PDM更好的保证了Java平台的安全性,在该机制中,JVM自带的Bootstrap是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM不会向Java程序提供对Bootstrap的引用。下面是关于几个类加载器的说明:

Bootstrap:一般用本地代码实现,负责加载JVM基础核心类库(rt.jar);

Extension:从java.ext.dirs系统属性所指定的目录中加载类库,它的父加载器是Bootstrap;

Application:应用类加载器,其父类是Extension。它是应用最广泛的类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中记载类,是用户自定义加载器的默认父加载器。

破坏双亲委派模型:线程上下文类加载器、OSGi类加载器

线程上下文类加载器:Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

OSGi类加载器:作代码热替换(HotSwap)、模块热部署(Hot Deployment)等用途。

Java内存模型

Java内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。

1)Java内存模型将内存分为了主内存工作内存。类的状态,也就是类之间共享的变量,是存储在主内存中的,每次Java线程用到这些主内存中的变量的时候,会读一次主内存中的变量,并让这些内存在自己的工作内存中有一份拷贝,运行自己线程代码的时候,用到这些变量,操作的都是自己工作内存中的那一份。在线程代码执行完毕之后,会将最新的值更新到主内存中去

2)定义了几个原子操作,用于操作主内存和工作内存中的变量

3)定义了volatile变量的使用规则

4)happens-before,即先行发生原则,定义了操作A必然先行发生于操作B的一些规则,比如在同一个线程内控制流前面的代码一定先行发生于控制流后面的代码、一个释放锁unlock的动作一定先行发生于后面对于同一个锁进行锁定lock的动作等等,只要符合这些规则,则不需要额外做同步措施,如果某段代码不符合所有的happens-before规则,则这段代码一定是线程非安全的

延伸阅读

Java内存模型中涉及到的概念有:

主内存:java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。可以与前面说的物理机的主内存相比,只不过物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。

工作内存:java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。可以与前面说的高速缓存相比。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

内存间交互操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

不允许read和load、store和write操作之一单独出现

不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现

如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值

如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

Jvm垃圾收集算法

  • 标记-清除算法(基础算法)

    算法分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

    不足:1.效率问题,标记和清除的效率都不高;2.空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致以后程序运行过程中,需要分配较大对象时,无法找到足够的连续的内存而不得不提前触发另一次垃圾收集动作。

  • 复制算法

    为了解决效率问题,将内存划分为大小相等的两块,每次只使用其中的一块,当这块内存快用完的时候,就将还存活的对象复制到令一块,然后把已使用的内存空间一次性清理掉。这样每次都是对一半的内存区域进行回收,也没有了内存碎片的问题。只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。缺点就是内存只能使用一半,空间上浪费。

    在java堆中新生代中对象朝生夕死存在的情况比较多,大约98%的对象需要回收。因此新生代是将内存分为Eden(伊甸园)空间和两块survivor(幸存者)空间,每次使用Eden和一块survivor空间。当进行垃圾回收时,将eden和一块survivor中还存活的对象,都复制到另一块survivor空间,然后将eden和survivor直接清除。HotSpot虚拟机,默认eden和survivor比例为8:1:1。98%的对象需要回收,只是大多数情况,我们没办法保证每次回收,都不多于10%的对象能够存活,当survivor空间不够用时,需要依赖其他内存进行分配担保(老年代)。如果另一块survivor空间没有足够的内存,存放上一次eden和survivor存活下来的对象,这些对象讲直接通过内存担保机制进入老年代。

  • 标记-整理算法

    针对老年代中对象的特点,回收率低。因此采用标记整理算法。和标记清除算法类似,在清除完成后,将存活的对象往一端移动,然后直接清理掉边界以外的内存区域。

  • 分代收集

    就是指的是,在新生代采用复制算法。在老年代采用标记清除或者标记整理算法。

JVM常用基本配置参数

-Xms 初始大小内存,默认为物理内存1/64,等价于-XX:InitialHeapSize

-Xmx 最大分配内存,默认为物理内存1/4,等价于-XX:MaxHeapSize

-Xss 设置单个线程栈的大小,一般默认为512k~1024k,等价于-XX:ThradStackSize

-Xmn 设置年轻代大小

-XX:MetaspaceSize 设置元空间大小

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制

-Xms10m -Xmx10m -XX:MetespaceSize=1024m -XX:+PrintFlagsFinal

-XX:+PrintGCDetails 输出详细GC收集日志信息

-XX:SurvivorRatio 设置新生代中eden和s0/s1空间的比例,默认-XX:SurvivorRatio=8,Eden:s0:s1=8:1:1,假如-XX:SurvivorRatio=4,Eden:s0:s1=4:1:1,SurvivorRatio值就是设置eden区的比例占多少,s0/s1相同

-XX:NewRatio 配置年轻代与老年代在堆结构的占比,默认-XX:NewRatio=2,新生代占1,老年代占2,年轻代占整个堆的1/3;假如-XX:NewRatio=4新生代占1,老年代占4,年轻代占整个堆的1/5,NewRatio值就是设置老年代的占比,剩下的1给新生代

-XX:MaxTenuringThreshold 设置垃圾最大年龄,默认值为15,有效值在0到15之间,-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年经代即被回收的概率。

强引用、软引用、弱引用、虚引用

强引用:当内存不足,JVM开始垃圾回收,对于强引用对象,就算是出现了OOM也不会对该对象进行回收,死都不收。

软引用:是一种相对强引用弱化了一些的引用,需要用java.lang.ref.SoftReference类来实现,可以让对象豁免一些垃圾收集。对于只有软引用的对象来说,当系统内存充足时它不会被回收,当系统内存不足时会被回收。软引用通常用在对内存敏感的程序中,比如高速缓存就有和到软引用,内存够用的时候就保留,不够用就回收。

弱引用:需要用java.lang.ref.WeakReference类来实现,它比软引用的生存周期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM的内存空间是否足够,都会回收该对象占用的内存

虚引用:需要java.lang.ref.PhantomReference类来实现。顾名思义,就是形同虚设,与其它几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(ReferenceQueue联合实用)。设置虚引用关联 唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步的处理。

垃圾收集器

串行垃圾收集器(Serial):它为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程。所以不适合服务器环境。

并行垃圾收集器(Parallel):多个垃圾收集线程并行工作,此时用户线程是暂停的,适用于科学计算/大数据处理后台处理等弱交互场景

并发垃圾收集器(CMS):用户线程和垃圾收集线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程,互联网公司多用它,适用对响应时间有要求的场景

G1垃圾回收器:G1垃圾回收器将堆内存分割成不同的区域然后并发的对其进行垃圾回收

并发标记清除GC(CMS)的过程

初始标记(CMS initial mark):只是标记一下GC Roots能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。

并发标记(CMS concurrent mark):和用户线程一起,进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。主要标记过程,标记全部对象。

重新标记(CMS remark):为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。由于并发标记时,用户线程依然运行,因此在正式清理前,再做修正。

并发清除(CMS concurrent sweep):和用户线程一起,清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。基于标记结果,直接清理对象。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户线程一起并发工作,所以总体上来看CMS收集器的内存回收和用户线程是一起并发地执行。

CMS的缺点

浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然会有新垃圾产生,这部分垃圾得标记过程之后,所以CMS无法在当收集中处理掉他们,只好留待下一次GC清理掉,这一部分垃圾称为浮动垃圾。在jdk1.5默认设置下,CMS收集器当老年代使用了68%的空间就会被激活,可以通过-XX:CMSInitialOccupancyFraction的值来提高触发百分比,在jdk1.6中CMS启动阈值提升到了92%,要是CMS运行期间预留的内存无法满足程序的需要,就会出现”Concurrent Mode Failure“,然后降级临时启用Serial Old收集器进行老年代的垃圾收集,这样停顿时间就很长了,所以-XX:CMSInitialOccupancyFraction设置太高容易导致大量”Concurrent Mode Failure“。

有空间碎片:CMS是一款基于“标记-清除”算法实现的,所以会产生空间碎片。为了解决这个问题,CMS提供了-XX:UseCMSCompactAtFullCollection开发参数用于开启内存碎片的合并整理,由于内存整理是无法并行的,所以停顿时间会变长。还有-XX:CMSFullGCBeforeCompaction,这个参数用于设置多少次不压缩Full GC后,跟着来一次带压缩的(默认为0)。

并发消耗CPU资源,CMS默认启动的回收线程数是(cpu数量+3)/4。所以CPU数量少会导致用户程序执行速度降低较多。

GC安全点一般选择的位置

  • 循环的末尾
  • 方法临返回前
  • 调用方法之后
  • 抛异常的位置

GC发生时,线程到最近的安全点的方式

抢断式中断:在GC发生时,首先中断所有线程,如果发现线程未执行到safe point,就恢复线程让其运行到safe point 上

主动式中断:在GC发生时,不直接操作线程中断,而是简单地设置一个标志,让各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。

JVM 采取的就是主动式中断。轮询标志的地方和安全点是重合的。

什么是GC安全域

Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。

线程在进入 Safe Region 的时候先标记自己已进入了 Safe Region,等到被唤醒时准备离开 Safe Region 时,先检查能否离开,如果 GC 完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止。

如何选择垃圾收集器

单CPU或小内存,单机程序 -XX:+UseSerialGC

多CPU,需要最大吞吐量,如后台计算型应用 -XX:+UseParllelGC 或者 -XX:+UseParllelOldGC

多CPU,追求低停顿时间,需快速响应如互联网应用 -XX:+UseConcMarkSweepGC -XX:+ParNewGC

并发收集器和并行收集器的区别

  • 并行收集器(parallel) :多条垃圾收集线程同时进行工作,此时用户线程处于等待状态
  • 并发收集器(concurrent):指多条垃圾收集线程与用户线程同时进行(但不一定是并行的,有可能交替进行工作)

JVM自动内存管理,Minor GC 与 Full GC的触发机制

**Minor GC触发条件:**当Eden区满时,触发Minor GC。

Full GC触发条件:
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

(5)由Eden区、From Survivor区向To Survivor区复制时,对象大小大于To Survivor可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

对象什么时候会进入老年代

  • 根据对象年龄
    JVM会给对象增加一个年龄(age)的计数器,对象每“熬过”一次GC,年龄就要+1,待对象到达设置的阈值(默认为15岁)就会被移移动到老年代,可通过-XX:MaxTenuringThreshold调整这个阈值。一次Minor GC后,对象年龄就会+1,达到阈值的对象就移动到老年代,其他存活下来的对象会继续保留在新生代中。
  • 动态年龄判断
    根据对象年龄有另外一个策略也会让对象进入老年代,不用等待15次GC之后进入老年代,他的大致规则就是,假如当前放对象的Survivor,一批对象的总大小大于这块Survivor内存的50%,那么大于这批对象年龄的对象,就可以直接进入老年代了。
  • 大对象直接进入老年代
    如果设置了-XX:PretenureSizeThreshold这个参数,那么如果你要创建的对象大于这个参数的值,比如分配一个超大的字节数组,此时就直接把这个大对象放入到老年代,不会经过新生代。这么做就可以避免大对象在新生代,屡次躲过GC,还得把他们来复制来复制去的,最后才进入老年代,这么大的对象来回复制,是很耗费时间的。

JVM调优基本思路

基本思路就是让每一次GC都回收尽可能多的对象

对于CMS收集器来说,最重要的是合理地设置年轻代和年老代的大小。年轻代太小的话,会导致频繁的Minor GC,并且很有可能存活期短的对象也不能被回收,GC的效率就不高。而年老代太小的话,容纳不下从年轻代过来的新对象,会频繁触发单线程Full GC,导致较长时间的GC暂停,影响Web应用的响应时间。

对于G1收集器来说,不推荐直接设置年轻代的大小,这一点跟CMS收集器不一样,这是因为G1收集器会根据算法动态决定年轻代和年老代的大小。因此对于G1收集器,需要关心的是Java堆的总大小(-Xmx)。此外G1还有一个较关键的参数是-XX:MaxGCPauseMillis = n,这个参数是用来限制最大的GC暂停时间,目的是尽量不影响请求处理的响应时间

如何确定年轻代、老年代内存大小?

这是一个迭代的过程,可以先采用JVM的默认值,然后通过压测分析GC日志。

如果看年轻代的内存使用率处在高位,导致频繁的Minor GC,而频繁GC的效率又不高,说明对象没那么快能被回收,这时年轻代可以适当调大一点。

如果看年老代的内存使用率处在高位,导致频繁的Full GC,这样分两种情况:如果每次Full GC后年老代的内存占用率没有下来,可以怀疑是内存泄漏;如果Full GC后年老代的内存占用率下来了,说明不是内存泄漏,要考虑调大年老代。

对于G1收集器来说,可以适当调大Java堆,因为G1收集器采用了局部区域收集策略,单次垃圾收集的时间可控,可以管理较大的Java堆。

synchronized底层怎么实现的

synchronized代码块是由一对monitorenter/monitorexit指令实现的, Monitor对象是同步的基本实现单元。Jdk1.6之前synchronized只有重量级锁的一种实现方式,jdk1.6之后进行了锁优化,细分分为偏向锁轻量级锁重量级锁

偏向锁本质就是无锁,如果没有发生过任何多线程争抢锁的情况,JVM认为就是单线程,无需做同步。轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令置换ThreadID

在发生争抢锁的情况下,偏向锁会变为轻量级锁或者未锁定状态。在轻量级锁的状态下,线程会通过自旋的方式抢锁(也就是自旋锁,自旋锁是轻量级锁的一种实现方式),在自旋到一定次数后,还没抢到锁,就会升级为重量级锁。

重量级锁机制包含三个比较重要的感念:EntryList队列、WaitSet队列、Owner。EntryList里存有等待抢锁的线程,其状态为Blocked;当线程调用对象的wait()方法后,线程会进入WaitSet队列,其状态为Waiting;Owner的值则表示当前持有锁的线程,若当前没有线程持有锁,则为null

ReentrantLock和Synchronized区别

相同点:阻塞式同步加锁,如果一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须阻塞在同步块外面等待

不同点

Synchronized:它是java语言的关键字,是原生语法层面的互斥,由jvm实现

ReentrantLock:它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成

便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。

锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized

延伸阅读:

1.Synchronized

Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。

2.ReentrantLock

由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:

等待可中断:持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。通过lock.lockInterruptibly()来实现这个机制。

公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。

锁绑定多个条件:一个ReentrantLock对象可以同时绑定多个对象。ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程

ReenTrantLock实现的原理:

ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。

序列化底层怎么实现的

什么是序列化和反序列化

序列化就是把实体对象状态按照一定的格式写入到有序字节流,反序列化就是从有序字节流重建对象,恢复对象状态。

为什么需要序列化与反序列化

  • 永久性保存对象,保存对象的字节序列到本地文件或者数据库中;
  • 通过序列化以字节流的形式使对象在网络中进行传递和接收;
  • 通过序列化在进程间传递对象;

序列化算法一般步骤

  • 将对象实例相关的类元数据输出。
  • 递归地输出类的超类描述直到不再有超类。
  • 类元数据完了以后,开始从最顶层的超类开始输出对象实例的实际数据值。
  • 从上至下递归输出实例的数据

JDK类库中序列化的步骤

  • 创建一个对象输出流,它可以包装一个其它类型的目标输出流,如文件输出流

    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("D:\\object.out"));
    
  • 通过对象输出流的writeObject()方法写对象

    oos.writeObject(new User("xuliugen", "123456", "male"));
    

JDK类库中反序列化的步骤

  • 创建一个对象输入流,它可以包装一个其它类型输入流,如文件输入流

    ObjectInputStream ois= new ObjectInputStream(new FileInputStream("object.out"));
    
  • 通过对象输入流的readObject()方法读取对象

    User user = (User) ois.readObject();
    

Java对象头实现

HotSpot虚拟机中,对象在内存中的布局分为三块区域:对象头实例数据对齐填充
对象头
对象头包括:Mark Word类型指针数组长度(只有数组对象才有)。

Mark Word
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。

类型指针
类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。

hash: 保存对象的哈希码
age: 保存对象的分代年龄
biased_lock: 偏向锁标识位
lock: 锁状态标识位
JavaThread:保存持有偏向锁的线程ID
epoch: 保存偏向时间戳

int的取值范围

int的取值范围为: -2^31 ~ 2^31-1,即 -2147483648 ~ 2147483647

HashMap原理

(1)HashMap是一种散列表,采用(数组 + 链表 + 红黑树)的存储结构,数组的一个元素又称作桶;

(2)HashMap的默认初始容量为16(1<<4),默认装载因子为0.75f,容量总是2的n次方;

(3)HashMap扩容时每次容量变为原来的两倍;

(4)当桶的数量小于64时不会进行树化,只会扩容;

(5)当桶的数量大于64且单个桶中元素的数量大于8时,进行树化;

(6)当单个桶中元素数量小于6时,进行反树化;

(7)HashMap是非线程安全的容器;

(8)HashMap查找添加元素的时间复杂度都为O(1);

ConcurrentHashMap原理

(1)ConcurrentHashMap是HashMap的线程安全版本;

(2)ConcurrentHashMap采用(数组 + 链表 + 红黑树)的结构存储元素;

(3)ConcurrentHashMap相比于同样线程安全的HashTable,效率要高很多;

(4)ConcurrentHashMap采用的锁有 synchronized,CAS,自旋锁,分段锁,volatile等;

(5)ConcurrentHashMap中没有threshold和loadFactor这两个字段,而是采用sizeCtl来控制;

(6)sizeCtl = -1,表示正在进行初始化;

(7)sizeCtl = 0,默认值,表示后续在真正初始化的时候使用默认容量;

(8)sizeCtl > 0,在初始化之前存储的是传入的容量,在初始化或扩容后存储的是下一次的扩容门槛;

(9)sizeCtl = (resizeStamp << 16) + (1 + nThreads),表示正在进行扩容,高位存储扩容邮戳,低位存储扩容线程数加1;

(10)更新操作时如果正在进行扩容,当前线程协助扩容;

(11)更新操作会采用synchronized锁住当前桶的第一个元素,这是分段锁的思想;

(12)整个扩容过程都是通过CAS控制sizeCtl这个字段来进行的,这很关键;

(13)迁移完元素的桶会放置一个ForwardingNode节点,以标识该桶迁移完毕;

(14)元素个数的存储也是采用的分段思想,类似于LongAdder的实现;

(15)元素个数的更新会把不同的线程hash到不同的段上,减少资源争用;

(16)元素个数的更新如果还是出现多个线程同时更新一个段,则会扩容段(CounterCell);

(17)获取元素个数是把所有的段(包括baseCount和CounterCell)相加起来得到的;

(18)查询操作是不会加锁的,所以ConcurrentHashMap不是强一致性的;

(19)ConcurrentHashMap中不能存储key或value为null的元素;

HashMap为什么是线程不安全的

  • JDK1.7中,在多线程环境下,扩容时会造成环形链数据丢失
  • JDK1.8中,在多线程环境下,会发生数据覆盖的情况。

Hashmap扩容时每个entry需要再计算一次hash吗?

不需要

CAS(Compare And Swap)的三个问题

  • 循环+CAS,自旋的实现让所有线程都处于高频运行,争抢CPU执行时间的状态。如果操作长时间不成功,会带来很大的CPU资源消耗。
  • 仅针对单个变量的操作,不能用于多个变量来实现原子操作。
  • ABA问题

乐观锁

乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。

使用乐观锁就不需要借助数据库的锁机制了。

乐观锁的概念中其实已经阐述了它的具体实现细节。主要就是两个步骤:冲突检测和数据更新。其实现方式有一种比较典型的就是CAS(Compare and Swap)。

CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

使用版本号的概念可以解决CAS的ABA问题。

悲观锁

对一个数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)

悲观锁的流程:

  • 在对记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。
  • 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
  • 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
  • 期间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常

用的MySql Innodb引擎举例,如何使用悲观锁:

要使用悲观锁,我们必须关闭MySQL数据库的自动提交属性。因为MySQL默认使用autocommit模式,也就是说,当我们执行一个更新操作后,MySQL会立刻将结果进行提交。(sql语句:set autocommit=0)

使用扣减库存的需求说明一下悲观锁的使用:

// 开始事务
begin;
// 查询出商品库存信息
select quantity from items where id=1 for update// 修改商品库存为2
update items set quantity=2 where id = 1;
// 提交事务
commit;

以上,在对id = 1的记录修改前,先通过for update的方式进行加锁,然后再进行修改。这就是比较典型的悲观锁策略。

如果以上修改库存的代码发生并发,同一时间只有一个线程可以开启事务并获得id=1的锁,其它的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。

上面我们提到,使用select…for update会把数据给锁住,不过我们需要注意一些锁的级别,MySQL InnoDB默认行级锁。行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。

ThreadPoolExecutor构造参数解释

完整的线程池构造函数:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize:核心线程数,线程池创建后就会初始化的线程数量

  • maximumPoolSize:最大线程数,当等待队列已满,就会增加线程,但新增后线程总量不会超过maximumPoolSize

  • keepAliveTime:超出核心线程数之外的线程存活时间

  • unit:线程存活时间格式,见枚举TimeUnit

  • workQueue:线程的等待队列,当没有空闲线程处理任务时,任务会进入该等待队列对待执行

  • threadFactory:线程工厂,可以自定义创建线程的工厂,该参数一般可以不配置,选择其他构造函数

  • handler:拒绝策略,在最大线程数已满,等待队列已满的情况下,新来的任务会执行拒绝策略。

JDK线程池自带的拒绝策略

AbortPolicy(丢弃抛异常策略)

DiscardPolicy(丢弃不抛异常策略)

DiscardOldestPolicy(丢弃队列最前任务策略)

CallerRunsPolicy(线程调度者执行策略)

线程池任务执行过程

2020 Java相关面试题整理_第1张图片

怎么实现一个线程池?

  • 首先得有一个集合保存线程,可以使用HashSet
  • 需要一个队列来存储提交给线程池的任务,可以使用ArrayBlockingQueue
  • 需要一个初始化线程池的大小属性
  • 需要一个属性保存已经工作的线程数量
  • 最后就是编写任务的执行方法。主要逻辑包含两点:如果线程池未满,每加入一个任务则开启一个线程;线程池已满,放入任务队列,等待有空闲线程时执行

同步阻塞、同步非阻塞、异步阻塞、异步非阻塞区别

2020 Java相关面试题整理_第2张图片

Java线程池,execute跟submit的区别

  • 对返回值的处理不同
    execute方法不关心返回值。
    submit方法有返回值Future。
  • 对异常的处理不同
    excute方法会抛出异常。
    sumbit方法不会抛出异常,除非你调用Future.get()。

什么情况下会出现OutOfMemoryError

  • Java堆溢出
  • 虚拟机栈和本地方法栈溢出
  • 方法区和运行时常量池溢出
  • 本地直接内存溢出

常见的阻塞队列有哪些?它的特点是什么?

  • ArrayBlockingQueue

    这是一个由数组结构组成的有界阻塞队列

  • LinkedBlockingQueue

    这是一个由链表结构组成的有界阻塞队列。LinkedBlockingQueue 可以不指定队列的大小,默认值是 Integer.MAX_VALUE 。但是,最好不要这样做,建议指定一个固定大小。因为,如果生产者的速度比消费者的速度大的多的情况下,这会导致阻塞队列一直膨胀,直到系统内存被耗尽(此时,还没达到队列容量的最大值)。此外,LinkedBlockingQueue 实现了读写分离,可以实现数据的读和写互不影响,这在高并发的场景下,对于效率的提高无疑是非常巨大的。

  • SynchronousQueue

    这是一个没有缓冲的无界队列。什么意思,看一下它的 size 方法:总是返回 0 ,因为它是一个没有容量的队列。当执行插入元素的操作时,必须等待一个取出操作。也就是说,put元素的时候,必须等待 take 操作。那么,有的同学就好奇了,这没有容量,还叫什么队列啊,这有什么意义呢。我的理解是,这适用于并发任务不大,而且生产者和消费者的速度相差不多的场景下,直接把生产者和消费者对接,不用经过队列的入队出队这一系列操作。所以,效率上会高一些。可以去查看一下 Excutors.newCachedThreadPool 方法用的就是这种队列。这个队列有两个构造方法,用于传入是公平还是非公平,默认是非公平。

  • PriorityBlockingQueue

    这是一个支持优先级排序的无界队列。可以指定初始容量大小(注意初始容量并不代表最大容量),或者不指定,默认大小为 11。也可以传入一个比较器,把元素按一定的规则排序,不指定比较器的话,默认是自然顺序。PriorityBlockingQueue 是基于二叉树最小堆实现的,每当取元素的时候,就会把优先级最高的元素取出来。

  • DelayQueue

    这是一个带有延迟时间的无界阻塞队列。队列中的元素,只有等延时时间到了,才能取出来。此队列一般用于过期数据的删除,或任务调度。

    特点:当阻塞队列为空的时候,从队列中取元素的操作就会被阻塞。当阻塞队列满的时候,往队列中放入元素的操作就会被阻塞。而后,一旦空队列有数据了,或者满队列有空余位置时,被阻塞的线程就会被自动唤醒。

线程池如何销毁超过核心线程数之外的线程?

线程池内的每个线程都会循环调用获取任务的函数getTask(),这个函数的返回值有两种情况:一是返回任务,二是返回null。如果返回任务则继续执行任务,当返回null的时候表明当前线程可以销毁了,因为没有任务可以执行,会调用销毁线程函数processWorkerExit。在getTask()函数内会判断当前是否允许销毁线程,如果允许则调用workQueue.poll,这个函数会等待keepAliveTime的时长,如果在这个时长内无法返回任务,则返回null;如果不允许销毁线程,则会调用workQueue.take这个函数,阻塞直到有任务为止,这也是为什么线程池能保证线程存活的原因。

Runnable接口和Callable接口的区别

Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;

Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。

CyclicBarrier和CountDownLatch的区别

  • CyclicBarrier的某个线程运行到某个点上之后,该线程即停止运行,直到所有的线程都到达了这个点,所有线程才重新运行;CountDownLatch则不是,某线程运行到某个点上之后,只是给某个数值-1而已,该线程继续运行。

  • CyclicBarrier只能唤起一个任务,CountDownLatch可以唤起多个任务。

  • CyclicBarrier可重用,CountDownLatch不可重用,计数值为0该CountDownLatch就不可再用了。

Semaphore有什么作用

Semaphore就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果Semaphore构造函数中传入的int型整数n=1,相当于变成了一个synchronized了。

volatile关键字的作用

  • 多线程主要围绕可见性和原子性两个特性而展开,使用volatile关键字修饰的变量,保证了其在多线程之间的可见性,即每次读取到volatile变量,一定是最新的数据。

  • 现实中,为了获取更好的性能JVM可能会对指令进行重排序,多线程下可能会出现一些意想不到的问题。使用volatile则会对禁止语义重排序,当然这也一定程度上降低了代码执行效率。

从实践角度而言,volatile的一个重要作用就是和CAS结合,保证了原子性

如何保证内存可见性

  • volatile通过内存屏障
  • synchronized通过修饰的程序段同一时间只能由同一线程运行,释放锁前会刷新到主内存

Java中如何获取到线程dump文件

1)获取到进程程的pid,可以通过使用jps命令,在Linux环境下还可以使用ps -ef | grep java

2)打印线程堆栈,可以通过使用jstack pid命令,在Linux环境下还可以使用kill -3 pid

一个线程如果出现了运行时异常会怎么样

如果这个异常没有被捕获的话,这个线程就停止执行了。另外重要的一点是:如果这个线程持有某个某个对象的监视器,那么这个对象监视器会被立即释放

sleep方法和wait方法有什么区别

sleep方法和wait方法都可以用来放弃CPU一定的时间,不同点在于如果线程持有某个对象的监视器,sleep方法不会放弃这个对象的监视器,wait方法会放弃这个对象的监视器

生产者消费者模型的作用是什么

  • 通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,这是生产者消费者模型最重要的作用

  • 解耦,这是生产者消费者模型附带的作用,解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要收到相互的制约

怎么检测一个线程是否持有对象监视器

Thread类提供了一个holdsLock(Object obj)方法,当且仅当对象obj的监视器被某条线程持有的时候才会返回true,注意这是一个static方法,这意味着"某条线程"指的是当前线程。

Java中用到的线程调度算法是什么

抢占式。一个线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。

Thread.sleep(0)的作用是什么

由于Java采用抢占式的线程调度算法,因此可能会出现某条线程常常获取到CPU控制权的情况,为了让某些优先级比较低的线程也能获取到CPU控制权,可以使用Thread.sleep(0)手动触发一次操作系统分配时间片的操作,这也是平衡CPU控制权的一种操作。

什么是AQS

简单说一下AQS,AQS全称为AbstractQueuedSychronizer,翻译过来应该是抽象队列同步器

如果说java.util.concurrent的基础是CAS的话,那么AQS就是整个Java并发包的核心了,ReentrantLock、CountDownLatch、Semaphore等等都用到了它。AQS实际上以双向队列的形式连接所有的Entry,比方说ReentrantLock,所有等待的线程都被放在一个Entry中并连成双向队列,前面一个线程使用ReentrantLock好了,则双向队列实际上的第一个Entry开始运行。

AQS定义了对双向队列所有的操作,而只开放了tryLock和tryRelease方法给开发者使用,开发者可以根据自己的实现重写tryLock和tryRelease方法,以实现自己的并发功能。

----点击查看更多2020面试题系列文章----

end
学而时习之

你可能感兴趣的:(Java,面试题)