1.自我介绍

Ⅰ.Java基础

1、谈一谈你对Java平台的理解?“Java 是解释执行”,这句话正确吗?

  Java是一种面向对象的语言。说到Java就会想到他的两大特性,一是“Write once,run anyway”,这靠的是他的JVM来隔离各种具体的操作环境,二是垃圾回收,JVM里内置的垃圾回收器和各种垃圾回收算法可以自动回收分配内存,大部分情况程序员是不用自己操心内存分配的。

  “Java是解释执行的”,这句话不太对。首先一般情况下我们编写的Java代码会通过Javac编译成字节码,然后在运行时由JVM内置的解释器把字节码转换成最终的机器码,这种代码可以看成解释执行的。但是在实际运用中,我们常用的Oracle JDK提供的Hotspot JVM内置有JIT编译器。顾名思义JIT就是Just In Time,这个编译器会把Hoespot热点代码直接编译成机器码,这部分热点代码就属于编译执行而非解释执行。

  更具体来说,Java的编译和C++的编译是不同的,编译Java生成的“.class”文件里面是字节码,而非机器码,需要等到执行时才会生成机器码。正是这种字节码和机器码的抽象,使得Java屏蔽了操作系统和硬件的区别,可以到处执行。运行时,JVM会通过类加载器(Class-Loader)加载字节码,解释或者编译执行。主流版本中,JDK8就是解释、编译混合的模式,而JIT对应的热点代码分析也有优化,Oracle Hotspot JVM 内置了两个不同的JIT compiler,C1、C2.C1用于客户端(cilent),最高进行1500次调用分析热点代码进行调优;C2用于服务器端(Sever)会进行上万次调用来调优。

  JVM虚拟机启动时可以指定不同参数对运行模式进行选择。例如,指定“-Xint”就是告诉JVM只进行解释执行而不进行编译,这样可以说完全舍弃了JIT的功能。指定“-Xcomp”则是告诉JVM只进行编译执行,不进行解释执行,这样会导致JVM启动速度非常慢。当然最新的还有AOT(Ahead of Time Compilation),直接讲字节码译成机器码,减少了JIT预热时间,JDK9就引入了AOT,新增了jaotc工具,使用jaotc命令预先把某个类库中所有代码直接编译成机器码,启动时直接指定即可。

  以上告诉我们一个道理:没有一个问题是加一个中间层解决不了的,如果解决不了就加两个。

2、Java的四个特性

  面向对象的四大特性:封装、继承、多态、抽象。Object类是任何类的默认父类,其包含的常用方法有getClass()、toString()、Object()、clone()、finalize()、hashCode()、equal()、wait()、notify()。

  封装是决定类中信息是否公开以及公开等级,即以什么方式暴露哪些信息,从而实现对属性、数据、部分敏感内容的隐藏。故推荐在不知道给什么权限时,优先给private,对属性值的修改也是通过getter/setter方法,而非直接对public的属性进行修改和读取。接口实现体现can-do关系,interface定义接口,implements实现接口。

  PS:不要在getter/setter方法里面写业务逻辑代码,会很难发现错误。

  继承即某些基础模块可以直接复用、间接复用或增强复用,父类通过这种方法把能力赋予子类,即is—a关系,需要复合里氏代换原则(LSP)。由于继承的成本很低,故使用时应注意方法污染和方法爆炸。故提倡组合优先原则来拓展类的能力,即优先采用组合或聚合类关系来复用其他类的能力。extends继承父类。

3、谈谈接口和抽象类有什么区别?

  接口和抽象类是 Java 面向对象设计的两个基础机制。

  接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。接口,不能实例化;不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。Java 标准类库中,定义了非常多的接口,比如 java.util.List。

  抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用。除了不能实例化,形式上和一般的Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。Java 标准库中,比如collection 框架,很多通用部分就被抽取成为抽象类,例如 java.util.AbstractList。

Java 类实现 interface 使用 implements 关键词,继承 abstract class 则是使用 extends 关键词,我们可以参考 Java 标准库中的 ArrayList

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{ 
    //... 
} 

使用时机:当想要支持多重继承,或是为了定义一种类型请使用接口;当打算提供带有部分实现的“模板”类,而将一些功能需要延迟实现请使用抽象类;当你打算提供完整的具体实现请使用类。

  Java 8 增加了函数式编程的支持,所以又增加了一类定义,即所谓 functional interface,简单 说就是只有一个抽象方法的接口,通常建议使用 @FunctionalInterface Annotation 来标记。 Lambda 表达式本身可以看作是一类 functional interface,某种程度上这和面向对象可以算是两码事。我们熟知的 Runnable、Callable 之类,都是 functional interface,这里不再多介绍 了,有兴趣你可以参考:https://www.oreilly.com/learning/java-8-functional-interfaces 。

  还有一点可能让人感到意外,严格说,Java 8 以后,接口也是可以有方法实现的!从 Java 8 开始,interface 增加了对 default method 的支持。Java 9 以后,甚至可以定义 private default method。Default method 提供了一种二进制兼容的扩展已有接口的办法。比 如,我们熟知的 java.util.Collection,它是 collection 体系的 root interface,在 Java 8 中添 加了一系列 default method,主要是增加 Lambda、Stream 相关的功能。我在专栏前面提到 的类似 Collections 之类的工具类,很多方法都适合作为 default method 实现在基础接口里 面。

4、类的加载过程

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

  首先是加载阶段(Loading),它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文 件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。
加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。

  第二阶段是链接(Linking),这是核心的步骤,简单说是把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:
     验证(Verification),这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载。
     准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不 会去执行更进一步的 JVM 指令。
     解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。

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

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

  另外,需要注意的是,在 Java 9 中,Jigsaw 项目为 Java 提供了原生的模块化支持,内建的类加载器结构和机制发生了明显变化。

首先,从架构角度,一起来看看 Java 8 以前各种类加载器的结构,下面是三种 Oracle JDK 内建的类加载器。

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

  扩展类加载器(Extension or Ext Class-Loader),负责加载我们放到 jre/lib/ext/ 目录下 面的 jar 包,这就是所谓的 extension 机制。该目录也可以通过设置 “java.ext.dirs”来覆 盖。

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

那为什么会有这个双亲委派模型呢??

采用双亲委派模型使得java类随着它的类加载器一起具备了一种带有优先级的层次关系。

例如类java.lang.Object,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都会委派给启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果用户自己写了一个名为java.lang.Object的类,并放在程序的Classpath中,那系统中将会出现多个不同的Object类,java类型体系中最基础的行为也无法保证,应用程序也会变得一片混乱。

为了避免用户自定义的类覆盖核心类库,比如用户也可以自定义Object,如果没有双亲委派机制,那么自定义的Object就会被应用程序类加载器加载,这会和核心类库的Object发生冲突。(没有说明白,应该是:双亲委派模型是对JVM的一种保护机制,即核心类库优先被上层类加载器加载而不会受自定义类的影响,比如自定义一个java.lang.Object,那么由于双亲委派模型,java.lang.Object的加载请求会优先被引导类加载器加载,而引导类加载器则会加载jre/lib下的java.lang.Object而不是加载用户自定义的)

  在 JDK 9 中,由于 Jigsaw 项目引入了 Java 平台模块化系统(JPMS),Java SE 的源代码被划 分为一系列模块。类加载器,类文件容器等都发生了非常大的变化,我这里总结一下:

  前面提到的 -Xbootclasspath 参数不可用了。API 已经被划分到具体的模块,所以上文中, 利用“-Xbootclasspath/p”替换某个 Java 核心类型代码,实际上变成了对相应的模块进行 的修补,可以采用下面的解决方案:

 首先,确认要修改的类文件已经编译好,并按照对应模块(假设是 java.base)结构存放,然后,给模块打补丁:java --patch-module java.base=your_patch yourApp

  扩展类加载器被重命名为平台类加载器(Platform Class-Loader),而且 extension 机制则 被移除。也就意味着,如果我们指定 java.ext.dirs 环境变量,或者 lib/ext 目录存在,JVM 将直接返回错误!建议解决办法就是将其放入 classpath 里。

  部分不需要 AllPermission 的 Java 基础模块,被降级到平台类加载器中,相应的权限也被更 精细粒度地限制起来。rt.jar 和 tools.jar 同样是被移除了!JDK 的核心类库以及相关资源,被存储在 jimage 文件 中,并通过新的 JRT 文件系统访问,而不是原有的 JAR 文件系统。虽然看起来很惊人,但幸 好对于大部分软件的兼容性影响,其实是有限的,更直接地影响是 IDE 等软件,通常只要升 级到新版本就可以了。

  增加了 Layer 的抽象, JVM 启动默认创建 BootLayer,开发者也可以自己去定义和实例化 Layer,可以更加方便的实现类似容器一般的逻辑抽象。

  结合了 Layer,目前的 JVM 内部结构就变成了下面的层次,内建类加载器都在 BootLayer 中, 其他 Layer 内部有自定义的类加载器,不同版本模块可以同时工作在不同的 Layer。

加载器可以有多个命名空间吗?JVM可以多次加载同一个jar包吗?

java中类的唯一性是由加载器和类的全限定名共同确定的,所以一个类是可以被不同加载器加载多次的。

5.强引用、软引用、弱引用、幻象引用有什么区别?

  在Java语言中,除了基本数据类型外,其他的都是指向各类对象的对象引用;Java中根据其生命周期的长短,将引用分为4类。

1 强引用

  特点:我们平常典型编码Object obj = new Object()中的obj就是强引用。通过关键字new 创建的对象所关联的引用就是强引用。 当JVM内存空间不足,JVM宁愿抛出OutOfMemory Error运行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对 象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。

2 软引用

  特点:软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemory Error 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合 使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与 之关联的引用队列中。后续,我们可以调用ReferenceQueue的poll()方法来检查是否有它所 关心的对象被回收。如果队列为空,将返回一个null,否则该方法返回队列中前面的一个Refer ence对象。

  应用场景:软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

3 弱引用

  弱引用通过WeakReference类实现。 弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与 否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收 弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所 引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

  应用场景:弱应用同样可用于内存敏感的缓存。

4 虚引用

  特点:虚引用也叫幻象引用,通过PhantomReference类来实现。无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾 回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回 收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到 与之关联的引用队列中。 ReferenceQueue queue = new ReferenceQueue (); PhantomReference pr = new PhantomReference (object, queue); 程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾 回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存 被回收之前采取一些程序行动。

  应用场景:可用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。

面向对象

1、Integer和Int有什么区别,Integer中有哪些特殊的函数

  int 是我们常说的整形数字,是 Java 的 8 个原始数据类型(Primitive Types,boolean、byte 、short、char、int、float、double、long)之一。Java 语言虽然号称一切都是对象,但原始数据类型是例外。

  Integer 是 int 对应的包装类,它有一个 int 类型的字段存储数据,并且提供了基本操作,比如数学运算、int和字符串之间转换等。在 Java 5中,引入了自动装箱和自动拆箱功能 (boxing/unboxing),Java可根据上下文,实现int/Integer,double/Dou ble,boolean/Boolean等基本类型与相应对象之间的自动转换,为开发过程带来极大便利。

 关于 Integer 的值缓存,这涉及 Java 5 中另一个改进。构建 Integer 对象的传统方式是直接调用构造器,直接 new 一个对象。但是根据实践,我们发现大部分数据操作都是集中在有限的、较小的数值范围,因而,在 Java 5 中新增了静态工厂方法 valueOf,在调用它的时候会利用一个缓存机制,带来了明显的性能改进。也就是说使用该方法后,如果两个对象的int值相同且落在缓存值范围内,那么这个两个对象就是同一个对象;当值较小且频繁使用时,推荐优先使用整型池方法(时间与空间性能俱佳)。按照 Javadoc,这个值默认缓存是 -128 到 127 之间,当然可以调一些JVM参数进行这个值得调整。

但是上面的这个IntegerCache.cache中的可复用Integer对象可以说是个大坑。在《阿里巴巴Java开发手册》中,强制所有相同类型的包装类对象的值比较,全部使用equals方法,部分原因使用==判断时本应该是false但是在这个cache上的对象会被判为true。

2、Java的finalize,finally,final三个关键字的区别和应用场景(Java中的final关键字怎么使用)

  final 可以用来修饰类、方法、变量,分别有不同的意义,final 修饰的 class 代表不可以继承扩展,final 的变量是不可以修改的,而 final 的方法也是不可以重写的(override)。

  finally 则是 Java 保证重点代码一定要被执行的一种机制。我们可以使用 try-finally 或者 trycatch-finally 来进行类似关闭 JDBC 连接、保证 unlock 锁等动作。当然也有情况会导致其无法执行,如进程被kill;try-catch异常退出exit().

  finalize 是基础类 java.lang.Object 的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。对于 finalize,我们要明确它是不推荐使用的,业界实践一再证明它不是个好的办法,在 Java 9 中,甚至明确将 Object.finalize() 标记为 deprecated!如果没有特别的原因,不要实现 finalize 方法,也不要指望利用它来进行资源回收。因为,你无法保证 finalize 什么时候执行,执行的是否符合预期。使用不当会影响性能,导致程序死锁、挂起等。并且因为是GC中的额外处理部分,不当使用会导致GC性能下降几十倍。

  Java 平台目前在逐步使用 java.lang.ref.Cleaner 来替换掉原有的 finalize 实现。Cleaner 的实 现利用了幻象引用(PhantomReference),这是一种常见的所谓 post-mortem 清理机制。 当然Cleaner仍有其缺陷。

  我们可以将方法或者类声明为 final,这样就可以明确告知别人,这些行为是不许修改的。java.lang 包下面的相当一部分类都被声明成为final class,并且在第三方类库的一些基础类中同样如此,这可以有效避免 API 使用者更改基础功能,某种程度上,这是保证平台安全的必要手段。

3、 String、StringBuffer、StringBuilder有什么区别?

  String 是 Java 语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型的 Immutable 类,被声明成为 final class,所有属性也都是 final 的。也由于它的不可变性, 类似拼接、裁剪字符串等动作,都会产生新的 String 对象。由于字符串操作的普遍性,所以相 关操作的效率往往对应用性能有明显影响。

  StringBuffer 是为解决上面提到拼接产生太多中间对象的问题而提供的一个类,它是 Java 1.5 中新增的,我们可以用 append 或者 add 方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer 本质是一个线程安全的可修改字符序列,它保证了线程安全,也随之带来了额 外的性能开销,所以除非有线程安全的需要,不然还是推荐使用它的后继者,也就是 StringBuilder。

  StringBuilder 在能力上和 StringBuffer 没有本质区别,但是它去掉了线程安全的部分,有效减小了开销,是绝大部分情况下进行字符串拼接的首选。

  由于String在Java世界中使用过于频繁,Java为了避免在一个系统中产生大量的String对象, 引入了字符串常量池。其运行机制是:创建一个字符串时,首先检查池中是否有值相同的字 符串对象,如果有则不需要创建直接从池中刚查找到的对象引用;如果没有则新建字符串对 象,返回对象引用,并且将新创建的对象放入池中。但是,通过new方法创建的String对象是 不检查字符串池的,而是直接在堆区或栈区创建一个新的对象,也不会把对象放入池中。上述原则只适用于通过直接量给String对象引用赋值的情况。

String str1 = "123"; //通过直接量赋值方式,放入字符串常量池 
String str2 = new String(“123”);//通过new方式赋值方式,不放入字符串常量池

  注意:String提供了inter()方法。调用该方法时,如果常量池中包括了一个等于此String对象 的字符串(由equals方法确定),则返回池中的字符串。否则,将此String对象添加到池 中,并且返回此池中对象的引用。

  StringBuffer 实现的一些细节,它的线程安全是通过把各种修改数据的方法都加 上 synchronized 关键字实现的,非常直白。为了实现修改字符序列的目的,StringBuffer 和 StringBuilder 底层都是利用可修改的(char, JDK 9 以后是 byte)数组,二者都继承了 AbstractStringBuilder,里面包含了基本操作,区别 仅在于终的方法是否加了 synchronized。

  在没有线程安全问题的情况下,全部拼接操作是应该都用 StringBuider 实现吗?毕竟这样书写代码,还是要多敲很多字的,可读性也不理想,下面的对比非常明显。其实,在通常情况下,没有必要过于担心,要相信 Java 还是非常智能的。可以看到,在 JDK 8 中,字符串拼接操作“+”会自动被 javac 转换为 StringBuilder 操作,而在 JDK 9 里面则是因为 Java 9 为了更加统一字符串操作优化,提供了 StringConcatFactory,作 为一个统一的入口。javac 自动生成的代码,虽然未必是优化的,但普通场景也足够了,你可以酌情选择。

  更具体的字符串的优化,可以调一些JVM参数。

JVM

1、介绍一下JVM

  通常可以把 JVM 内存区域分为下面几个方面,其中,有的区域是以线程为单位,而有的区域则是整个 JVM 进程唯一的。

  首先,程序计数器(PC,Program Counter Register)。在 JVM 规范中,每个线程都有它自己的程序计数器,并且任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行本地 法,则是未指定值(undefined)。

  第二,Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈。每个线程在创建时 都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方 法调用。前面谈程序计数器时,提到了当前方法;同理,在一个时间点,对应的只会有一个活动的栈帧,通常叫作当前帧,方法所在的类叫作当前类。如果在该方法中调用了其他方法,对应的新的栈帧 会被创建出来,成为新的当前帧,一直到它返回结果或者执行结束。JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈。
栈帧中存储着局部变量表、操作数(operand)栈、动态链接、方法正常退出或者异常退出的定 义等。

  第三,堆(Heap),它是 Java 内存管理的核心区域,用来放置 Java 对象实例,几乎所有创建 的 Java 对象实例都是被直接分配在堆上。堆被所有的线程共享,在虚拟机启动时,我们指定 的“Xmx”之类参数就是用来指定最大堆空间等指标。理所当然,堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一 步的细分,最有名的就是新生代、老年代的划分。

  第四,方法区(Method Area)。这也是所有线程共享的一块内存区域,用于存储所谓的元 (Meta)数据,例如类结构信息,以及对应的运行时常量池、字段、方法代码等。
由于早期的 Hotspot JVM 实现,很多人习惯于将方法区称为永久代(Permanent Generation)。Oracle JDK 8 中将永久代移除,同时增加了元数据区(Metaspace)。

  第五,运行时常量池(Run-Time Constant Pool),这是方法区的一部分。如果仔细分析过反 编译的类文件结构,你能看到版本号、字段、方法、超类、接口等各种信息,还有一项信息就是 常量池。Java 的常量池可以存放各种常量信息,不管是编译期生成的各种字面量,还是需要在 运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛。

  第六,本地方法栈(Native Method Stack)。它和 Java 虚拟机栈是非常相似的,支持对本地 方法的调用,也是每个线程都会创建一个。在 Oracle Hotspot JVM 中,本地方法栈和 Java 虚 拟机栈是在同一块儿区域,这完全取决于技术实现的决定,并未在规范中强制。

2、JVM的分代回收以及具体算法

年代视角的堆结构

你可以看到,按照通常的 GC 年代方式划分,Java 堆内分为:
1. 新生代

  新生代是大部分对象创建和销毁的区域,在通常的 Java 应用中,绝大部分对象生命周期都是很 短暂的。其内部又分为 Eden 区域,作为对象初始分配的区域;两个 Survivor,有时候也叫 from、to 区域,被用来放置从 Minor GC 中保留下来的对象。JVM 会随意选取一个 Survivor 区域作为“to”,然后会在 GC 过程中进行区域间拷贝,也 就是将 Eden 中存活下来的对象和 from 区域的对象,拷贝到这个“to”区域。这种设计主 要是为了防止内存的碎片化,并进一步清理无用对象。

  从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,Hotspot JVM 还有一个概念叫做 Thread Local Allocation Buffer(TLAB),据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。这是 JVM 为每个线程分配的一个私有缓存区域,否则,多线程同时分配内存时,为避免操作同一地址,可能需要使用加锁等机制,进而影响分配速度, 你可以参考下面的示意图。从图中可以看出,TLAB 仍然在堆上,它是分配在 Eden 区域内 的。其内部结构比较直观易懂,start、end 就是起始地址,top(指针)则表示已经分配到 哪里了。所以我们分配新对象,JVM 就会移动 top,当 top 和 end 相遇时,即表示该缓存 已满,JVM 会试图再从 Eden 里分配一块儿。
Eden

  1. 老年代

  放置长生命周期的对象,通常都是从 Survivor 区域拷贝过来的对象。当然,也有特殊情况,我们知道普通的对象会被分配在 TLAB 上;如果对象较大,JVM 会试图直接分配在 Eden 其他位 置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。

  1. 永久代

  这部分就是早期 Hotspot JVM 的方法区实现方式了,储存 Java 类元数据、常量池、Intern 字 符串缓存,在 JDK 8 之后就不存在永久代这块儿了。

3、JVM的垃圾收集器,G1和CMS有什么区别

  实际上,垃圾收集器(GC,Garbage Collector)是和具体 JVM 实现紧密相关的,不同厂商 (IBM、Oracle),不同版本的 JVM,提供的选择也不同。接下来,我来谈谈最主流的 Oracle JDK。

  Serial GC,它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进 行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也 意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式 下 JVM 的默认选项。 从年代的角度,通常将其老年代实现单独称作 Serial Old,它采用了标记 - 整理(Mark Compact)算法,区别于新生代的复制算法。 Serial GC 的对应 JVM 参数是:
  -XX:+UseSerialGC

  ParNew GC,很明显是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作,下面是对应参数
  *-XX:+UseConcMarkSweepGC -XX:+UseParNewGC *

  CMS(Concurrent Mark Sweep) GC,基于标记 - 清除(Mark-Sweep)算法,设计目标 是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍 然有很多系统使用 CMS GC。但是,CMS 采用的标记 - 清除算法,存在着内存碎片化问 题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,既然强调了 并发(Concurrent),CMS 会占用更多 CPU 资源,并和用户线程争抢。
它是对于响应时间的重要性需求大于吞吐量要求的收集器。对于要求服务器响应速度高的情况下,使用CMS非常合适。

  Parrallel GC,在早期 JDK 8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作 是吞吐量优先的 GC。它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是新生 代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效。 开启选项是:
  -XX:+UseParallelGC
  另外,Parallel GC 引入了开发者友好的配置项,我们可以直接设置暂停时间或吞吐量等目标, JVM 会自动进行适应性调整,例如下面参数:
  -XX:MaxGCPauseMillis=value -XX:GCTimeRatio=N // GC 时间和用户时间比例 = 1 / (N+1)

  G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后的默认 GC 选 项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情 况下的延时停顿,但是最差情况要好很多。 G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的 一个个 region。Region 之间是复制算法,但整体上实际可看作是标记 - 整理(MarkCompact)算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更 加明显。
G1 吞吐量和停顿表现都非常不错,并且仍然在不断地完善,与此同时 CMS 已经在 JDK 9 中 被标记为废弃(deprecated),所以 G1 GC 值得你深入掌握。

  GC 仍然处于飞速发展之中,目前的默认选项 G1 GC 在不断的进行改进,很多我们原来认为的缺点,例如串行的 Full GC、Card Table 扫描的低效等,都已经被大幅改进,例如, JDK 10 以 后,Full GC 已经是并行运行,在很多场景下,其表现还略优于 Parallel GC 的并行 Full GC 实 现。

  即使是 Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不 管是 GC 相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在 Serverless 等新的应用场景下,Serial GC 找到了新的舞台。

  比较不幸的是 CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但是已经被标记为废弃,如果没有组织主动承担 CMS 的维护,很有可能会在未来版本移除。

  如果你有关注目前尚处于开发中的 JDK 11,你会发现,JDK 又增加了两种全新的 GC 方式,分 别是:Epsilon GC,简单说就是个不做垃圾收集的 GC,似乎有点奇怪,有的情况下,例如在进行 性能测试的时候,可能需要明确判断 GC 本身产生了多大的开销,这就是其典型应用场景。ZGC,这是 Oracle 开源出来的一个超级 GC 实现,具备令人惊讶的扩展能力,比如支持 T bytes 级别的堆大小,并且保证绝大部分情况下,延迟都不会超过 10 ms。虽然目前还处于 实验阶段,仅支持 Linux 64 位的平台,但其已经表现出的能力和潜力都非常令人期待。

常见的垃圾收集算法,我认为总体上有个了解,理解相应的原理和优缺点,就已经足够了,其主要分为三类:

  复制(Copying)算法,我前面讲到的新生代 GC,基本都是基于复制算法,将活着的对象复制到 to 区域,拷贝过程中将对象顺序放置,就可以避免内存 碎片化。 这么做的代价是,既然要进行复制,既要提前预留内存空间,有一定的浪费;另外,对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象 引用关系,这个开销也不小,不管是内存占用或者时间开销。

  标记 - 清除(Mark-Sweep)算法,首先进行标记工作,标识出所有要回收的对象,然后进 行清除。这么做除了标记、清除过程效率有限,另外就是不可避免的出现碎片化问题,这就 导致其不适合特别大的堆;否则,一旦出现 Full GC,暂停时间可能根本无法接受。

  标记 - 整理(Mark-Compact),类似于标记 - 清除,但为避免内存碎片化,它会在清理过 程中将对象移动,以确保移动后的对象占用连续的内存空间。

  注意,这些只是基本的算法思路,实际 GC 实现过程要复杂的多,目前还在发展中的前沿 GC 都 是复合算法,并且并行和并发兼备。

4、垃圾回收的过程

在垃圾收集的过程,对应到 堆的Eden、 Survivor、Tenured 等区域会发生什么变化呢?

  这实际上取决于具体的 GC 方式,先来熟悉一下通常的垃圾收集流程,我画了一系列示意图,希望能有助于你理解清楚这个过程。

  第一,Java 应用不断创建对象,通常都是分配在 Eden 区域,当其空间占用达到一定阈值时, 触发 minor GC。仍然被引用的对象(绿色方块)存活下来,被复制到 JVM 选择的 Survivor 区 域,而没有被引用的对象(黄色方块)则被回收。注意,我给存活对象标记了“数字 1”,这是 为了表明对象的存活时间。

垃圾回收
  第二, 经过一次 Minor GC,Eden 就会空闲下来,直到再次达到 Minor GC 触发条件,这时 候,另外一个 Survivor 区域则会成为 to 区域,Eden 区域的存活对象和 From 区域对象,都会 被复制到 to 区域,并且存活的年龄计数会被加 1。


  第三, 类似第二步的过程会发生很多次,直到有对象年龄计数达到阈值,这时候就会发生所谓 的晋升(Promotion)过程,如下图所示,超过阈值的对象会被晋升到老年代。这个阈值是可 以通过参数指定:
  -XX:MaxTenuringThreshold=


  后面就是老年代 GC,具体取决于选择的 GC 选项,对应不同的算法。下面是一个简单标记 - 整 理算法过程示意图,老年代中的无用对象被清除后, GC 会将对象进行整理,以防止内存碎片化。


  通常我们把老年代 GC 叫作 Major GC,将对整个堆进行的清理叫作 Full GC,但是这个也没有 那么绝对,因为不同的老年代 GC 算法其实表现差异很大,例如 CMS,“concurrent”就体现 在清理工作是与工作线程一起并发运行的。

5、Jvm的参数设置

1.调整堆大小
  那么,我们如何利用 JVM 参数,直接影响堆和内部区域的大小呢?我来简单总结一下:

  大堆体积
  -Xmx value

  初始的小堆体积
  -Xms value

  老年代和新生代的比例
  -XX:NewRatio=value

  默认情况下,这个数值是 3,意味着老年代是新生代的 3 倍大;换句话说,新生代是堆大小的 1/4。
  当然,也可以不用比例的方式调整新生代的大小,直接指定下面的参数,设定具体的内存大 小数值。
  -XX:NewSize=value

  Eden 和 Survivor 的大小是按照比例设置的,如果 SurvivorRatio 是 8,那么 Survivor 区域 就是 Eden 的 1/8 大小,也就是新生代的 1/10,因为 YoungGen=Eden + 2Survivor, JVM 参数格式是
  
-XX:SurvivorRatio=value*

  TLAB 当然也可以调整,JVM 实现了复杂的适应策略,如果你有兴趣可以参考这篇说明。

2.调整GC模式

3.防止OOM:
  了解 JVM 内存的方法有很多,具体能力范围也有区别,简单总结如下:

  可以使用综合性的图形化工具,如 JConsole、VisualVM(注意,从 Oracle JDK 9 开始, VisualVM 已经不再包含在 JDK 安装包中)等。这些工具具体使用起来相对比较直观,直接 连接到 Java 进程,然后就可以在图形化界面里掌握内存使用情况。

  以 JConsole 为例,其内存页面可以显示常见的堆内存和各种堆外部分使用状态。
 也可以使用命令行工具进行运行时查询,如 jstat 和 jmap 等工具都提供了一些选项,可以查 看堆、方法区等使用数据。
  或者,也可以使用 jmap 等提供的命令,生成堆转储(Heap Dump)文件,然后利用 jhat 或 Eclipse MAT 等堆转储分析工具进行详细分析。
  如果你使用的是 Tomcat、Weblogic 等 Java EE 服务器,这些服务器同样提供了内存管理相 关的功能。

  另外,从某种程度上来说,GC 日志等输出,同样包含着丰富的信息。
  这里有一个相对特殊的部分,就是是堆外内存中的直接内存,前面的工具基本不适用,可以使用 JDK 自带的 Native Memory Tracking(NMT)特性,它会从 JVM 本地内存分配的角度进行 解读。

6.了解JVM吗,能不能说一下GC是怎么停机的?

7.Server和Client模式下的GC算法有区别吗?

JVM工作在Server模式可以大大提高性能,但应用的启动会比client模式慢大概10%。当该参数不指定时,虚拟机启动检测主机是否为服务器,如果是,则以Server模式启动,否则以client模式启动,J2SE5.0检测的根据是至少2个CPU和最低2GB内存。

当JVM用于启动GUI界面的交互应用时适合于使用client模式,当JVM用于运行服务器后台程序时建议用Server模式。
JVM在client模式默认-Xms是1M,-Xmx是64M;JVM在Server模式默认-Xms是128M,-Xmx是1024M。我们可以通过运行:java -version来查看jvm默认工作在什么模式。

两种模式下的垃圾回收器,因为选取Server模式通常意味着硬件条件CPU、内存等较高,因此通常选择能够并行回收的,比如ParNew,Parallel Old

8.java中内存泄露是啥,什么时候出现内存泄露?

https://www.nowcoder.com/questionTerminal/3568ca9842ef4d78ba683e895a7a503c?orderByHotValue=1&mutiTagIds=639&page=1&onlyReference=false

9.minor gc如果运行的很频繁,可能是什么原因引起的,minor gc如果运行的很慢,可能是什么原因引起的?

集合

1、Java的集合框架?

Java集合框架图
回答闪光点:(看组成及底层源码,然后引导hashmap) Ps:超级多的东西。具体百度、知乎、博客查~

  上图主要可分为两类:一类是单个元素存储的Collection,在继承树中Set和List都是事先了Collcetion接口,一类是Key-Value存储的Map。其中与Collection相关的分别是Queue、List、Set、Map。

2、Java collection类,集合,讲两个你了解的,说实现原理

  Collection子类分为两类:一类是单个元素存储的Collection,在继承树中Set和List都是事先了Collcetion接口,一类是Key-Value存储的Map。其中与Collection相关的分别是Queue、List、Set、Map。选的话选Map底下的HashMap、List底下的ArrayList。

3、对比 Hashtable、HashMap、TreeMap 有什么不同?谈谈你对 HashMap 的掌握。

Hashtable、HashMap、TreeMap 都是最常见的一些 Map 实现,是以键值对的形式存储和操 作数据的容器类型。

  Hashtable 是早期 Java 类库提供的一个哈希表实现,本身是同步的(采用synchronized修饰),key、value都不能为null,由 于同步导致的性能开销,所以已经很少被推荐使用。

  HashMap 是应用更加广泛的哈希表实现,行为上大致上与 HashTable 一致,主要区别在于 HashMap 不是同步的,key、value可以为null,很显然只 能有一个key为null的键值对,但是允许有多个值为null的键值对。通常情况下,HashMap 进行 put 或者 get 操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个 用户 ID 和用户信息对应的运行时存储结构。

  TreeMap 则是基于红黑树的一种提供顺序访问的 Map,和 HashMap 不同,它的 get、put、 remove 之类操作都是 O(log(n))的时间复杂度,TreeMap中当未实现 Comparator 接口时,key 不可以为null;当实现 Comparator 接口时,若未对null情况进行判 断,则key不可以为null,反之亦然。

  hashmap、hashtable属于无序map,而 LinkedHashMap 和 TreeMap则为有序map,实现方法为LinkedHashMap 通常提供的是遍历顺序符合插入顺序,它的实现是通过为条目(键值对) 维护一个双向链表。注意,通过特定构造函数,我们可以创建反映访问顺序的实例,所谓的put、get、compute 等,都算作“访问”。所以一般需要排序的 情况下是选择TreeMap来进行,默认为升序排序方式(深度优先搜索),可自定义实现Com parator接口实现排序方式。

  初始化时:HashTable在不指定容量的情况下的默认容量为11,且不要求底层数组的容量一 定要为2的整数次幂;HashMap默认容量为16,且要求容量一定为2的整数次幂。 扩容时:Hashtable将容量变为原来的2倍加1;HashMap扩容将容量变为原来的2倍。

  需要注意的是,HashMap 在并发 环境可能出现无限循环占用 CPU、size 不准确等诡异的问题。这是一种典型的使用错误,因为 HashMap 明确声明不是线程安全的数据结构,如果忽 略这一点,简单用在多线程场景里,难免会出现问题。

4、haspmap,hashtable实现原理?(kv插入过程,HashMap能插入重复元素吗,key重复?value重复?hashmap的线程安全性)

回答闪光点:(看底层源码!!,如果能说出jdk1.7和jdk1.8区别是闪光点)

  Hashtable 比较特别,作为类似 Vector、Stack 的早期集合相关类型,它是扩展了 Dictionary 类的,类结构上与 HashMap 之类明显不同

  HashMap 等其他 Map 实现则是都扩展了 AbstractMap,里面包含了通用方法抽象。不同 Map 的用途,从类图结构就能体现出来,设计目的已经体现在不同接口上。大部分使用 Map 的场景,通常就是放入、访问或者删除,而对顺序没有特别要求,HashMap 在这种情况下基本是最好的选择。HashMap 的性能表现非常依赖于哈希码的有效性,请务必掌 握 hashCode 和 equals 的一些基本约定(见下面的问11)

HashMap 源码分析:

  首先,我们来一起看看 HashMap 内部的结构,它可以看作是数组(Node[] table)和链表结 合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的 寻址;哈希值相同的键值对,则以链表形式存储,你可以参考下面的示意图。这里需要注意的 是,如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),图中的链表就会被改造为树形结 构。

查看hashmap的源码实现,put方法实现主要靠的是putVal的调用,截取其中关键

final V putVal(int hash, K key, V value, boolean onlyIfAbent,boolean evit) {          Node<K,V>[] tab; Node<K,V> p; int , i;     
    if ((tab = table) == null || (n = tab.length) = 0)        
        n = (tab = resize()).legth;           //如果表格是 null,resize 方法会负责初始化它,即 tab = resize() 
    if ((p = tab[i = (n - 1) & hash]) == ull)       // 找到具体键值对在哈希表中的位置(数组 index)
        tab[i] = newNode(hash, key, value, nll);     
    else {         // ...        
        if (binCount >= TREEIFY_THRESHOLD - 1) // 链表结构(这里叫 bin),会在达到一定门限值时,发生树化
             //  ...       
    } 
} 

从 putVal 方法最初的几行,我们就可以发现几个有意思的地方:

如果表格是 null,resize 方法会负责初始化它,这从 tab = resize() 可以看出。resize 方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容 (resize)。即在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。

if (++size > threshold)     
    resize(); 

5、HashMap解决冲突的方法,Java里是怎么实现的

  本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能。链表长度达到阈值(默认为8)后会树化。而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端 CPU 大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。

6、Hashmap的大小为什么指定为2的幂次

  在hashmap的初始化中,源码是如果tab是null,则调用resize方法初始化。

final Node<K,V>[] resize() {     
    // ...     
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY && oldCap >=DEFAULT_INITIAL_CAPAITY) //MAXIMUM_CAPACIY最大容量 1~30
        newThr = oldThr << 1; // 扩容即*2
    // ...     
    else if (oldThr > 0) // 给一个容量参数
        newCap = oldThr;    
    else {   // 如果构建 HashMap 的时候没有指定参数,那么就是依 据相应的默认常量值。
        newCap = DEFAULT_INITIAL_CAPAITY;         
        newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY;    
    }     
    if (newThr ==0) {  //元素超过门限值时即扩容
        float ft = (float)newCap * loadFator;         
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Intege    
    }     
    threshold = neThr;     
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];     
    table = n; //将老的数组中的元素重新放置到新的数组
 } 

  依据 resize 源码,不考虑极端情况(容量理论最大极限由 MAXIMUM_CAPACITY 指定,数值 为 1<<30,也就是 2 的 30 次方),我们可以归纳为:

    门限值等于(负载因子)x(容量),如果构建 HashMap 的时候没有指定它们,那么就是依 据相应的默认常量值。
  门限通常是以倍数进行调整 (newThr = oldThr << 1),我前面提到,根据 putVal 中的逻 辑,当元素个数超过门限大小时,则调整 Map 大小。扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源。

查看更多

7、concurrenthashmap实现原理?

回答闪光点:(看底层源码!!,同样需要jdk1.7和jdk1.8区别)

首先,ConcurrentHashMap 的设计实现其实一直在演化,比如在 Java 8 中就发 生了非常大的变化(Java 7 其实也有不少更新),所以,我这里将比较分析结构、实现机制等 方面,对比不同版本的主要区别。

  早期 ConcurrentHashMap,其实现是基于:分离锁,也就是将内部进行分段(Segment),里面则是 HashEntry 的数组,和 HashMap 类似,哈希相同的条目也是以链表形式存放。HashEntry 内部使用 volatile 的 value 字段来保证可见性,也利用了不可变对象的机制以改 进利用 Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性 能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。

  JDK7中的 ConcurrentHashMap,其特点有:ConcurrentHashMap 会获取再入锁,以保证数据一致性,Segment 本身就是基于 ReentrantLock 的扩展实现,所以,在并发修改期间,相应 Segment 是被锁定的。在最初阶段,进行重复性的扫描,以确定相应 key 值是否已经在数组里面,进而决定是更新 还是放置操作,你可以在代码里看到相应的注释。重复扫描、检测冲突是 ConcurrentHashMap 的常见技巧。

  HashMap 中可能发生的扩容问题,在 ConcurrentHashMap 中同样存在。不过有一个明显区别,就是它进行的不是整体的扩容,而是单独对 Segment 进行扩容,细节就不介绍了。

  在 Java 8 和之后的版本中,ConcurrentHashMap 的变化有:
  总体结构上,它的内部存储变得和 HashMap 结构非常相似,同样是大的桶(bucket)数组,然后内部也是一个个所谓的链表结构(bin),同步的粒度要更细致 一些。其内部仍然有 Segment 定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构 上的用处。
  因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免 初始开销,解决了老版本很多人抱怨的这一点。数据存储利用 volatile 来保证可见性。使用 CAS 等操作,在特定场景进行无锁并发操作。使用 Unsafe、LongAdder 之类底层手段,进行极端情况的优化。

8、为什么concurrenthashmap是线程安全的,hashmap是线程不安全的?

回答闪光点:(如果能自己打代码实践一下,则比较好,说出自己的实践)

  Java 提供了不同层面的线程安全支持。在传统集合框架内部,除了 Hashtable 等同步容器,还 提供了所谓的同步包装器(Synchronized Wrapper),我们可以调用 Collections 工具类提供 的包装方法,来获取一个同步的包装容器(如 Collections.synchronizedMap),但是它们都 是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。

  另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了:各种并发容器,比如 ConcurrentHashMap、CopyOnWriteArrayList。各种线程安全队列(Queue/Deque),如 ArrayBlockingQueue、SynchronousQueue。各种有序容器的线程安全版本等。具体保证线程安全的方式,包括有从简单的 synchronize 方式,到基于更加精细化的,比如基 于分离锁实现的 ConcurrentHashMap 等并发实现等。具体选择要看开发的场景需求,总体来 说,并发包内提供的容器通用场景,远优于早期的简单同步实现。

9、concurrent的扩容机制,什么时候扩容?

回答闪光点:(说出超过容量阈值即160.75=12时,会发生扩容等等)

首先依旧是《阿里巴巴Java开发手册》上的推荐规范:
  在集合初始化的时候,指定集合初始值的大小。
  HashMap使用HashMap(int initialCapacity)初始化。
  initialCapacity = (需要存储的元素个数/负载因子)+1.而负载因子默认值为0.75,如果暂时无法确定初始值大小,可设置为16(即默认大小)。
如果经计算可以得到大致初始值,应该准确填入。如果HashMap需要存放1024个元素,由于没有设置初始容量大小,则随着元素增多,容量会被迫扩容7次。而每次扩容都需要重建hash表,这将严重影响性能。

查看更多

10、hashset实现原理?(Set的实现)

回答闪光点:(底层有hashmap组成...说自己可以手写)。上面总体的闪光点。看底层源码,如果可以手写,就更完美了。

  Set,Set 是不允许重复元素的,这是和 List 最明显的区别,也就是不存在两个对象 equals 返回 true。我们在日常开发中有很多需要保证元素唯一性的场合。
  如果阅读过更多源码,你会发现,其实,TreeSet 代码里实际默认是利用 TreeMap 实现的, Java 类库创建了一个 Dummy 对象“PRESENT”作为 value,然后所有插入的元素其实是以键 的形式放入了 TreeMap 里面;同理,HashSet 其实也是以 HashMap 为基础实现的,原来他 们只是 Map 类的马甲

11、hashcode和equals

依旧是由《阿里巴巴Java开发手册》上的强制规范:
  1、只要重写了 equals就必须重写hashcode。
  2、因为set存储的是不重复的对象,依据hashcode和equals进行判断,所以set的存储对象必须重写这两种方法。
  3、如果自定义对象是map的键,那么必须重写hashcode和equals。

  equals 相等,hashCode 一定要相等。
  重写了 hashCode 也要重写 equals。

  hashCode 需要保持一致性,状态改变返回的哈希值仍然要一致。
  equals 的对称、反射、传递等特性。
  String重写了hashcode和equals方法,所以我么可以非常愉快地将String对象作为key来使用。

12、你重写过hashcode和equals么,要注意什么,不同时重写会出现哪些问题

13、Arraylist的原理

1 底层实现方式

  ArrayList内部用数组来实现;LinkedList内部采用双向链表实现;Vector内部用数组实现

2 读写机制

  ArrayList在执行插入元素是超过当前数组预定义的最大值时,数组需要扩容,扩容过程需要 调用底层System.arraycopy()方法进行大量的数组复制操作;在删除元素时并不会减少数组 的容量(如果需要缩小数组容量,可以调用trimToSize()方法);在查找元素时要遍历数组, 对于非null的元素采取equals的方式寻找。

  LinkedList在插入元素时,须创建一个新的Entry对象,并更新相应元素的前后元素的引用; 在查找元素时,需遍历链表;在删除元素时,要遍历链表,找到要删除的元素,然后从链表 上将此元素删除即可。 Vector与ArrayList仅在插入元素时容量扩充机制不一致。对于Vector,默认创建一个大小为 10的Object数组,并将capacityIncrement设置为0;当插入元素数组大小不够时,如果cap acityIncrement大于0,则将Object数组的大小扩大为现有size+capacityIncrement;如果c apacityIncrement<=0,则将Object数组的大小扩大为现有大小的2倍。

3 读写效率

  ArrayList对元素的增加和删除都会引起数组的内存分配空间动态发生变化。因此,对其进行插入和删除速度较慢,但检索速度很快。
  LinkedList由于基于链表方式存放数据,增加和删除元素的速度较快,但是检索速度较慢。

4 线程安全性

  ArrayList、LinkedList为非线程安全;Vector是基于synchronized实现的线程安全的ArrayList。
  **需要注意的是:单线程应尽量使用ArrayList,Vector因为同步会有性能损耗;即使在多线程环境下,我们可以利用Collections这个类中为我们提供的synchronizedList(List list)方法返回一个线程安全的同步列表对象。 **

14、内存屏障是什么

  阻止读写内存动作的重排序。比如,mfence阻止mfence指令之后的读写内存的动作被重排序到mfence之前,以及阻止mfence指令被重排序到更早的读写内存动作之前。sfence和mfence类似,但只是阻止写内存动作的重排序。lfence同样和mfence类似,但只是阻止读内存动作的重排序。

15、讲一下countDownLatch

  为了保证 Thread::start() 之后 Thread::tid() 立刻能拿到正确的值。如果没有 latch_,会有 race condition,即调用 Thread::tid() 的时候线程还没有启动,结果返回初值 0。struct ThreadData
{
void runInThread()
{
*tid_ = muduo::CurrentThread::tid();
tid_ = NULL;
latch_->countDown();
latch_ = NULL;
}
}

16、I++操作怎么保证线程安全

  AtomicInteger类

并发与多线程

learn more

1、进程和线程的区别

  从现在操作系统的角度,可以简单认为,线程是系统调度的最小单元,进程是资源分配的最小单元,一个进程可以包含多个线程, 作为任务的真正运作者,有自己的栈(Stack)、寄存器(Register)、本地存储(Thread Local)等,但是会和进程内其他线程共享文件描述符、虚拟地址空间等。

  在具体实现中,线程还分为内核线程、用户线程,Java 的线程实现其实是与虚拟机相关的。对 于我们最熟悉的 Sun/Oracle JDK,其线程也经历了一个演进过程,基本上在 Java 1.2 之后, JDK 已经抛弃了所谓的Green Thread,也就是用户调度的线程,现在的模型是一对一映射到操 作系统内核线程。
如果我们来看 Thread 的源码,你会发现其基本操作逻辑大都是以 JNI 形式调用的本地代码。

private native void start0();
private native void setPriority0(int newPriority); 
private native void interrupt0(); 

  这种实现有利有弊,总体上来说,Java 语言得益于精细粒度的线程和相关的并发操作,其构建 高扩展性的大型应用的能力已经毋庸置疑。但是,其复杂性也提高了并发编程的门槛,近几年的 Go 语言等提供了协程(coroutine),大大提高了构建并发应用的效率。于此同时,Java 也在 Loom项目中,孕育新的类似轻量级用户线程(Fiber)等机制,也许在不久的将来就可以在新 版 JDK 中使用到它。

2、Java新建线程有哪几种方式

  1.我们可以直接继承Thread类,然后实例化,实现方法run() 。

  2.实现Runnable接口,实现方法run(),将代码逻放在 Runnable 中,然后构建 Thread 并启动(start),等待结束 (join)。

Runnable task = () -> {System.out.println("Hello World!");}; 
Thread myThread = new Thread(task); 
myThread.start(); 
myThread.join(); 

  3.是实现Callable接口,接口中要覆盖的方法是 public call() 注意:此方法可以抛异常,而前两种不能 而且此方法可以有返回值

  在《阿里巴巴Java开发手册》中强制规定。线程资源必须通过线程池提供,不允许在应用中显式创建线程。

  使用线程池的好处是:减少在创建和销毁线程上所消耗的时间和系统资源,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

  Runnable 的好处是,不会受 Java 不支持类多继承的限制,重用代码实现,当我们需要重复执 行相应逻辑时优点明显。而且,也能更好的与现代 Java 并发库中的 Executor 之类框架结合使 用这样我们就不用操心线程的创建和管理,也能利用 Future 等机制更好地处理执行结果。线程生 命周期通常和业务之间没有本质联系,混淆实现需求和业务需求,就会降低开发的效率。

PS:一个线程两次调用 start() 方法会出现什么情况?谈谈线程的生命周期 和状态转移。

  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 等方法的指定超时版本,如下面示例:
public final native void wait(long timeout) throws InterruptedException;

  终止(TERMINATED),不管是意外退出还是正常执行结束,线程已经完成使命,终止运 行,也有人把这个状态叫作死亡。

  在第二次调用 start() 方法的时候,线程可能处于终止或者其他(非 NEW)状态,但是不论如 何,都是不可以再次启动的。

3、讲解线程池

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

  Executors 目前提供了 5 种不同的线程池创建配置:

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

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

  newSingleThreadExecutor(),它创建的是个 ScheduledExecutorService,也就是可以进 行定时或周期性的工作调度。工作线程数目被限制为 1,所以它保证了所有任务的都是被顺 序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避 免其改变线程数目。

  newScheduledThreadPool(int corePoolSize),同样是 ScheduledExecutorService,区别 在于它会保持 corePoolSize 个工作线程。

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

  在大多数应用场景下,使用 Executors 提供的 5 个静态工厂方法就足够了,但是仍然可能需要 直接利用 ThreadPoolExecutor 等构造函数创建,这就要求你对线程构造方式有进一步的了 解,你需要明白线程池的设计和结构。另外,线程池这个定义就是个容易让人误解的术语,因为 ExecutorService 除了通常意义 上“池”的功能,还提供了更全面的线程管理、任务提交等方法。

线程池处理任务的流程是怎样的?

4、线程安全问题的产生原因

  首先,我们需要理解什么是线程安全。《Java 并发编程实战》按照其中的定义,线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享 的、可修改的状态的正确性,这里的状态反映在程序中其实可以看作是数据。换个角度来看,如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题,进而可以 推理出保证线程安全的两个办法:

  封装:通过封装,我们可以将对象内部状态隐藏、保护起来。
  不可变:还记得我们在专栏第 3 讲强调的 final 和 immutable 吗,就是这个道理,Java 语 言目前还没有真正意义上的原生不可变,但是未来也许会引入。

  线程安全需要保证几个基本特性:

    原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
    可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为 将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
    有序性,是保证线程内串行语义,避免指令重排等。

保证线程安全的方法有哪些?

5、乐观锁和悲观锁

  至于悲观锁和乐观锁,也并不是 MySQL 或者数据库中独有的概念,而是并发编程的基本概念。 主要区别在于,操作共享数据时,“悲观锁”即认为数据出现冲突的可能性更大,而“乐观 锁”则是认为大部分情况不会出现冲突,进而决定是否采取排他性措施。

  反映到 MySQL 数据库应用开发中,悲观锁一般就是利用类似 SELECT … FOR UPDATE 这样的 语句,对数据加锁,避免其他事务意外修改数据。乐观锁则与 Java 并发包中的 AtomicFieldUpdater 类似,也是利用 CAS 机制,并不会对数据加锁,而是通过对比数据的时 间戳或者版本号,来实现乐观锁需要的版本判断。

  我认为前面提到的 MVCC,其本质就可以看作是种乐观锁机制,而排他性的读写锁、双阶段锁 等则是悲观锁的实现。有关它们的应用场景,你可以构建一下简化的火车余票查询和购票系统。同时查询的人可能很 多,虽然具体座位票只能是卖给一个人,但余票可能很多,而且也并不能预测哪个查询者会购 票,这个时候就更适合用乐观锁。

  《阿里巴巴Java开发手册》强制规定:在并发修改同一记录时,为了避免更新丢失,需要加锁。要么在应用层加锁,要么在缓存层加锁,要么在数据库层使用乐观锁,使用version作为更新依据。

  如果每次访问冲突概率小于20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次数不得小于3次。

6、乐观锁是怎么保证一致性的

  利用 CAS 机制,并不会对数据加锁,而是通过对比数据的时 间戳或者版本号,来实现乐观锁需要的版本判断。

7、什么情况下会发生死锁,解决策略有哪些

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

  定位死锁最常见的方式就是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进 而找到死锁。如果是比较明显的死锁,往往 jstack 等就能直接定位,类似 JConsole 甚至可以 在图形界面进行有限的死锁检测。如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问 题。所以,代码开发阶段互相审查,或者利用工具进行预防性排查,往往也是很重要的。

根据死锁产生的条件:互斥、请求保持、不可抢占、循环依赖、

第一种方法

  如果可能的话,尽量避免使用多个锁,并且只有需要时才持有锁。否则,即使是非常精通并发编 程的工程师,也难免会掉进坑里,嵌套的 synchronized 或者 lock 非常容易出问题。

第二种方法

  如果必须使用多个锁,尽量设计好锁的获取顺序,这个说起来简单,做起来可不容易,你可以参 看著名的银行家算法。

第三种方法

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

第四种方法

  业界也有一些其他方面的尝试,比如通过静态代码分析(如 FindBugs)去查找固定的模式,进 而定位可能的死锁或者竞争情况。实践证明这种方法也有一定作用,请参考相关文档。
除了典型应用中的死锁场景,其实还有一些更令人头疼的死锁,比如类加载过程发生的死锁,尤 其是在框架大量使用自定义类加载时,因为往往不是在应用本身的代码库中,jstack 等工具也不见得能够显示全部锁信息,所以处理起来比较棘手。对此,Java 有官方文档进行了详细解释, 并针对特定情况提供了相应 JVM 参数和基本原则。

8、synchronized 实现原理?

回答闪光点:具体怎么实现的,比如通过反汇编可以看到里面有一个status标 志...等等。

  synchronized 代码块是由一对儿 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。

  在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内 核态的切换,所以同步操作是一个无差别的重量级操作。

  现代的(Oracle)JDK 中,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实 现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。

  所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争 状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

  当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作(compare and swap),在对 象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正 的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中多会被一个线程锁 定,使用偏斜锁可以降低无竞争开销。

  如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并 切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就 使用普通的轻量级锁;否则,进一步升级为重量级锁。

  我注意到有的观点认为 Java 不会进行锁降级。实际上据我所知,锁降级确实是会发生的,当 JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级。

  synchronized 是 JVM 内部的 Intrinsic Lock,所以偏斜锁、轻量级锁、重 量级锁的代码实现,并不在核心类库部分,而是在 JVM 的代码中。

Java 代码运行可能是解释模式也可能是编译模式(如果不记得,请复习专栏第 1 讲),所以对 应的同步逻辑实现,也会分散在不同模块下:

  首先,synchronized 的行为是 JVM runtime 的一部分,所以我们需要先找到 Runtime 相关的 功能实现。通过在代码中查询类似“monitor_enter”或“Monitor Enter”,很直观的就可以 定位到:
  sharedRuntime.cpp/hpp,它是解释器和编译器运行时的基类。
  synchronizer.cpp/hpp,JVM 同步相关的各种基础逻辑。
在 sharedRuntime.cpp 中,下面代码体现了 synchronized 的主要逻辑。

Handle h_obj(THREAD, obj);   
if (UseBiasedLocking) {     
    // Retry fast entry if bias is revoked to avoid unnecessary inflation
    ObjectSynchronizer::fast_enter(h_obj, lock, true, CHECK);   
} 
else {     
    ObjectSynchronizer::slow_enter(h_obj, lock, CHECK);   
}

  1.UseBiasedLocking 是一个检查,因为,在 JVM 启动时,我们可以指定是否开启偏斜锁。偏斜锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真 正竞争的 synchronized 块儿时,才能体现出明显改善。实践中对于偏斜锁的一直是有争议的, 有人甚至认为,当你需要大量使用并发类库时,往往意味着你不需要偏斜锁。从具体选择来看, 我还是建议需要在实践中进行测试,根据结果再决定是否使用。

  2.fast_enter 是我们熟悉的完整锁获取路径,slow_enter 则是绕过偏斜锁,直接进入轻量级锁 获取逻辑。

9、synchronized优化都有什么?

  回答闪光点:轻量级锁,重量级锁,偏向锁,自旋锁...等等。需要说出都是怎么实现的,比如前三个锁为通过Mark Word标题头进行控制,具体的自己实践一下,说一下自己的实践,超加分。

  **自旋锁:竞争锁的失败的线程,并不会真实的在操作系统层面挂起等待,而是JVM会让线程做 几个空循环(基于预测在不久的将来就能获得),在经过若干次循环后,如果可以获得锁,那么 进入临界区,如果还不能获得锁,才会真实的将线程在操作系统层面进行挂起。 **

  适用场景:自旋锁可以减少线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的代码块 来说,有较大的性能提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。 如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,就不适合使用自旋锁 了,因为自旋锁在获取锁前一直都是占用cpu做无用功,线程自旋的消耗大于线程阻塞挂起操 作的消耗,造成cpu的浪费。切记,自旋锁只有在多核CPU上有效果,单核毫无效果,只是浪费时间。 在单核CPU上,自旋锁是无用,因为当自旋锁尝试获取锁不成功会一直尝试,这会一直占用C PU,其他线程不可能运行, 同时由于其他线程无法运行,所以当前线程无法释放锁。

优化:
  还有一方面是,偏斜锁会延缓 JIT 预热的进程,所以很多性能测试中会显式地关闭偏斜锁,命令 如下:
  -XX:-UseBiasedLocking

10、lock实现原理?

回答闪光点:底层由AQS组成...具体的看源码~

  以 ReentrantLock 为例,它内部通过扩展 AQS 实现了 Sync 类型,以 AQS 的 state 来反映锁 的持有情况。

private final Sync sync; 
abstract static class Sync extends AbstractQueuedSynchronizer { …} 

  下面是 ReentrantLock 对应 acquire 和 release 操作,如果是 CountDownLatch 则可以看作 是 await()/countDown(),具体实现也有区别。

public void lock() {     
    sync.acquire(1); 
} 
public void unlock() {     
    sync.release(1);
} 

并发包的基础技术 AQS

  下面我来介绍一下 AbstractQueuedSynchronizer(AQS),其是 Java 并发包中,实现各种同 步结构和部分其他组成单元(如线程池中的 Worker)的基础。学习 AQS,如果上来就去看它的一系列方法(下图所示),很有可能把自己看晕,这种似懂非 懂的状态也没有太大的实践意义。我建议的思路是,尽量简化一下,理解为什么需要 AQS,如何使用 AQS,至少要做什么,再进 一步结合 JDK 源代码中的实践,理解 AQS 的原理与应用。Doug Lea曾经介绍过 AQS 的设计初衷。从原理上,一种同步结构往往是可以利用其他的结构 实现的,例如我在专栏第 19 讲中提到过可以使用 Semaphore 实现互斥锁。但是,对某种同步 结构的倾向,会导致复杂、晦涩的实现逻辑,所以,他选择了将基础的同步相关操作抽象AbstractQueuedSynchronizer 中,利用 AQS 为我们构建同步结构提供了范本。

  AQS 内部数据和方法,可以简单拆分为:
    一个 volatile 的整数成员表征状态,同时提供了 setState 和 getState 方法
    private volatile int state;
    一个先入先出(FIFO)的等待线程队列,以实现多线程间竞争和等待,这是 AQS 机制的核 心之一。
    各种基于 CAS 的基础操作方法,以及各种期望具体同步结构去实现的 acquire/release 方 法。

  利用 AQS 实现一个同步结构,至少要实现两个基本类型的方法,分别是 acquire 操作,获取资 源的独占权;还有就是 release 操作,释放对某个资源的独占。

11、抢占锁和非抢占锁区别,怎么实现?
回答闪光点:先进先出队列...具体看源码。

12、synchronized 和 ReentrantLock 有什么区别?有人说 synchronized 慢,这话靠谱吗?

回答闪光点:说出原理,之后说区别,并且说什么时候用,之后自己再把代码实践的给他们讲一下。

  synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它提供了互斥 的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那 里。

  在 Java 5 以前,synchronized 是仅有的同步手段,在代码中, synchronized 可以用来修饰方 法,也可以使用在特定的代码块儿上,本质上 synchronized 方法等同于把方法全部语句用 synchronized 块包起来。ReentrantLock,通常翻译为再入锁,是 Java 5 提供的锁实现,它的语义和 synchronized 基 本相同。再入锁通过代码直接调用 lock() 方法获取,代码书写也更加灵活。与此同时, ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制, 比如可以控制 fairness,也就是公平性,或者利用定义条件等。但是,编码中也需要注意,必须 要明确调用 unlock() 方法释放,不然就会一直持有该锁。

  synchronized 和 ReentrantLock 的性能不能一概而论,早期版本 synchronized 在很多场景下 性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于 ReentrantLock。

  代码中使用 synchronized 非常便利,如果用来修饰静态方法,其等同于利用下面代码将方法体 囊括进来:
  *synchronized (ClassName.class) {} *

  再来看看 ReentrantLock。你可能好奇什么是再入?它是表示当一个线程试图获取一个它已经 获取的锁时,这个获取动作就自动成功,这是对锁获取粒度的一个概念,也就是锁的持有是以线 程为单位而不是基于调用次数。Java 锁实现强调再入性是为了和 pthread 的行为进行区分。

  再入锁可以设置公平性(fairness),我们可在创建再入锁时选择是否是公平的。
  ReentrantLock fairLock = new ReentrantLock(true);

  这里所谓的公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间久的线 程。公平性是减少线程“饥饿”(个别线程长期等待锁,但始终无法获取)情况发生的一个办 法。如果使用 synchronized,我们根本无法进行公平性的选择,其永远是不公平的,这也是主流操 作系统线程调度的选择。通用场景中,公平性未必有想象中的那么重要,Java 默认的调度策略 很少会导致 “饥饿”发生。与此同时,若要保证公平性则会引入额外开销,自然会导致一定的 吞吐量下降。所以,我建议只有当你的程序确实有公平性需要的时候,才有必要指定它。

查看更多重入锁相关知识

13、CAS操作是什么,产生什么问题?

回答闪光点:会产生ABA问题,之后怎么解决ABA问题。然后再什么时候会进行CAS操作,比如concurrenthashmap插入操作时,会进行CAS操作。

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

  CAS 更加底层是如何实现的,这依赖于 CPU 提供的特定指令,具体 根据体系结构的不同还存在着明显区别。比如,x86 CPU 提供 cmpxchg 指令;而在精简指令集的体系架构中,则通常是靠一对儿指令(如“load and reserve”和“store conditional”) 实现的,在大多数处理器上 CAS 都是个非常轻量级的操作,这也是其优势所在。

  CAS 也并不是没有副作用,试想,其常用的失败重试机制,隐含着一个假设,即竞争情况是短 暂的。大多数应用场景中,确实大部分重试只会发生一次就获得了成功,但是总是有意外情况, 所以在有需要的时候,还是要考虑限制自旋的次数,以免过度消耗 CPU。

  另外一个就是著名的ABA问题,这是通常只在 lock-free 算法下暴露的问题。我前面说过 CAS 是在更新时比较前值,如果对方只是恰好相同,例如期间发生了 A -> B -> A 的更新,仅仅判 断数值是 A,可能导致不合理的修改操作。针对这种情况,Java 提供了 AtomicStampedReference 工具类,通过为引用建立类似版本号(stamp)的方式,来保证 CAS 的正确性,具体用法请参考这里的介绍。

  前面介绍了 CAS 的场景与实现,幸运的是,大多数情况下,Java 开发者并不需要直接利用 CAS 代码去实现线程安全容器等,更多是通过并发包等间接享受到 lock-free 机制在扩展性上 的好处。

查看更多同步知识
14、Synchronize关键字为什么jdk1.5后效率提高了

15、线程池的使用时的注意事项

简单理解一下:

  工作队列负责存储用户提交的各个任务,这个工作队列,可以是容量为 0 的 SynchronousQueue(使用 newCachedThreadPool),也可以是像固定大小线程池(newFixedThreadPool)那样使用 LinkedBlockingQueue。
  private final BlockingQueue workQueue;

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

  线程池的工作线程被抽象为静态内部类 Worker,基于AQS实现。
  ThreadFactory 提供上面所需要的创建线程逻辑。如果任务提交时被拒绝,比如线程池已经处于 SHUTDOWN 状态,需要为其提供处理逻辑, Java 标准库提供了类似ThreadPoolExecutor.AbortPolicy等默认实现,也可以按照实际需求 自定义。从上面的分析,就可以看出线程池的几个基本组成部分,一起都体现在线程池的构造函数中,从 字面我们就可以大概猜测到其用意:

  corePoolSize,所谓的核心线程数,可以大致理解为长期驻留的线程数目(除非设置了 allowCoreThreadTimeOut)。对于不同的线程池,这个值可能会有很大区别,比如newFixedThreadPool 会将其设置为 nThreads,而对于 newCachedThreadPool 则是为 0。maximumPoolSize,顾名思义,就是线程不够时能够创建的最大线程数。同样进行对比, 对于 newFixedThreadPool,当然就是 nThreads,因为其要求是固定大小,而keepAliveTime 和 TimeUnit,这两个参数指定了额外的线程能够闲置多久,显然有些线程 池不需要它。workQueue,工作队列,必须是 BlockingQueue。

  通过配置不同的参数,我们就可以创建出行为大相径庭的线程池,这就是线程池高度灵活性的基 础。

  上面我已经介绍过,线程池大小不合适,太多会太少,都会导致麻烦,所以我们需要去考虑一个 合适的线程池大小。虽然不能完全确定,但是有一些相对普适的规则和思路。
如果我们的任务主要是进行计算,那么就意味着 CPU 的处理能力是稀缺的资源,我们能够通 过大量增加线程数提高计算能力吗?往往是不能的,如果线程太多,反倒可能导致大量的上 下文切换开销。所以,这种情况下,通常建议按照 CPU 核的数目 N 或者 N+1。

  如果是需要较多等待的任务,例如 I/O 操作比较多,可以参考 Brain Goetz 推荐的计算方 法:
  线程数 = CPU 核数 × (1 + 平均等待时间 / 平均工作时间)

  还有一点很重要,就是放在线程池中的线程要捕获异常,如果直接抛出异常,每次都 会创建线程,也就等于线程池没有发挥作用,如果大并发下一直创建线程可能会导致JVM挂 掉。

16、线程安全性,最大存活时间
17、讲一下线程状态转移图

18、消息队列了解么

  我们可以把消息队列比作是一个存放消息的容器,当我们需要使用消息的时候可以取出消息供自己使用。消息队列是分布式系统中重要的组件,使用消息队列主要是为了通过异步处理提高系统性能和削峰、降低系统耦合性。目前使用较多的消息队列有ActiveMQ,RabbitMQ,Kafka,RocketMQ,我们后面会一一对比这些消息队列。另外,我们知道队列 Queue 是一种先进先出的数据结构,所以消费消息时也是按照顺序来消费的。比如生产者发送消息1,2,3...对于消费者就会按照1,2,3...的顺序来消费。但是偶尔也会出现消息被消费的顺序不对的情况,比如某个消息消费失败又或者一个 queue 多个consumer 也会导致消息被消费的顺序不对,我们一定要保证消息被消费的顺序正确。除了上面说的消息消费顺序的问题,使用消息队列,我们还要考虑如何保证消息不被重复消费?如何保证消息的可靠性传输(如何处理消息丢失的问题)?......等等问题。所以说使用消息队列也不是十全十美的,使用它也会让系统可用性降低、复杂度提高,另外需要我们保障一致性等问题。

  在不使用消息队列服务器的时候,用户的请求数据直接写入数据库,在高并发的情况下数据库压力剧增,使得响应速度变慢。但是在使用消息队列之后,用户的请求数据发送给消息队列之后立即 返回,再由消息队列的消费者进程从消息队列中获取数据,异步写入数据库。由于消息队列服务器处理速度快于数据库(消息队列也比数据库有更好的伸缩性),因此响应速度得到大幅改善。通过以上分析我们可以得出消息队列具有很好的削峰作用的功能——即通过异步处理,将短时间高并发产生的事务消息存储在消息队列中,从而削平高峰期的并发事务。 举例:在电子商务一些秒杀、促销活动中,合理使用消息队列可以有效抵御促销活动刚开始大量订单涌入对系统的冲击。

19、分布式了解么

  分为分布式存储和分布式计算两大部分。

  分布式存储方式有:阿里云云存储OSS。

  分布式计算方式最为人熟知的就是Mapreduce了。

20、多线程实现方法?如何尽可能提高多线程并发性能?

21、进程间通信方式

22、volatile和synchronized

volatile关键字
  volatile关键字具有许多特性,其中最重要的特性就是保证了用volatile修饰的变量对所有线程的可见性。即作为一种轻量级锁使用。
  
 这里的可见性是什么意思呢?当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。
  
 为什么volatile关键字可以有这样的特性?这得益于java语言的先行发生原则。在计算机科学中,先行发生原则是两个事件的结果之间的关系,如果一个事件发生在另一个事件之前,结果必须反映,即使这些事件实际上是乱序执行的(通常是优化程序流程)。这里所谓的事件,实际上就是各种指令操作,比如读操作、写操作、初始化操作、锁操作等等。先行发生原则作用于很多场景下,包括同步锁、线程启动、线程终止、volatile。我们这里只列举出volatile相关的规则:对于一个volatile变量的写操作先行发生于后面对这个变量的读操作。
   
  《阿里巴巴Java开发手册》推荐规则:
  volatile解决多线程内存不可见问题。可以解决一写多读的变量同步问题;无法解决多写的同步问题。
  如果是count++操作,使用如下类实现:
java
AtomicInteger count = new AtomicInteger();
count.addAndGet(1);

  如果在JDK8中,推荐使用LongAdder对象,它比AtomicInteger性能更好(减少了乐观锁额的重试次数)。
volatile只提供了保证访问该变量时,每次都是从内存中读取最新值,并不会使用寄存器缓存该值——每次都会从内存中读取。
  而对该变量的修改,volatile并不提供原子性的保证。
  由于及时更新,很可能导致另一线程访问最新变量值,无法跳出循环的情况,对 volatile的操作,不会被保存,即用了volatile不会造成阻塞。
  多线程下计数器必须使用锁保护,不能只用volatile实现。

22.ThreadLocal原理

(线程局部变量,从另一角度解决并发访问,锁一般是以时间换空间,而ThreadLocal是以空间换时间)

23.wait方法和sleep方法的区别

sleep来自于Thread,wait来自Object,sleep没有释放锁且一般加时间限制,wait释放了锁一般不加时间限制 ,sleep需要捕捉异常,wait不用。Thread.sleep(0)是“触发操作系统立刻重新进行一次cpu竞争”

24.线程的sleep()方法和yield()方法有什么区别?

NIO

Java的IO类库的基本架构:Java.IO包下大致有80个类,大致可分为四组:
基于字节操作的IO接口:InputStream和OutputStream
基于字符操作的IO接口:Writer和Reader
基于磁盘操作额IO接口:File
基于网络操作的IO接口:Socket

  Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分(即所谓的 BIO、NIO、NIO 2(AIO))。

  首先,传统的 java.io 包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽 象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流 时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。

java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用 性能的瓶颈。很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、 HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。

  第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等 新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性 能数据操作方式。

  第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也 有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解 为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后 续工作。

IO 不仅仅是多路复用,NIO 2 也不仅仅是异步 IO

PS1:需要澄清一些基本概念:

  区分同步或异步(synchronous/asynchronous)。简单来说,同步是一种可靠的有序运行 机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步 则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序 关系。

  区分阻塞与非阻塞(blocking/non-blocking)。在进行阻塞操作时,当前线程会处于阻塞 状态,无法从事其他任务,只有当条件就绪才能继续,比如 ServerSocket 新连接建立完毕,或数据读取、写入操作完成;而非阻塞则是不管 IO 操作是否结束,直接返回,相应操作 在后台继续处理。

PS2:不能一概而论认为同步或阻塞就是点是NIO在不同的平台上的实现方式是不一样的,如果你工作用电脑是win,生产是 linux,那么建议直接在linux上调试和测试低效,具体还要看应用和系统特征。

查看更多

1、怎么打印日志?

2、运行时异常与一般异常有何异同?

3、error和exception有什么区别?

4、给我一个你最常见到的runtime exception

5、Java中的异常处理机制的简单原理和应用。

6、java中有几种类型的流?JDK为每种类型的流提供了一些抽象类以供继承,请说出他们分别是哪些类?

7、什么是java序列化,如何实现java序列化?

8、运行时异常与受检异常有什么区别?