程序员社区

Java面试题总结(下)

六、极客时间

1、Exception和Error有什么区别?

Error类和Exception类的父类都是throwable类,他们的区别是:

Error类一般是指与JVM虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢等。对于这类错误的导致的应用程序中断,仅靠程序本身无法恢复和和预防,遇到这样的错误,建议让程序终止。

Exception类表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常。

1.1 举例说明常见的Error和Exception?

Java面试题总结(下)插图

Exception和Error都是继承了Throwable类,在Java中只有Throwable类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

1.2 ClassNotFoundException和NoClassDefFoundError的区别?

NoClassDefFoundError是一个错误(Error),而ClassNOtFoundException是一个异常,在Java中错误和异常是有区别的,我们可以从异常中恢复程序但却不应该尝试从错误中恢复程序

ClassNotFoundException的产生原因:Java支持使用Class.forName方法来动态地加载类,任意一个类的类名如果被作为参数传递给这个方法都将导致该类被加载到JVM内存中,如果这个类在类路径中没有被找到,那么此时就会在运行时抛出ClassNotFoundException异常。

NoClassDefFoundError的产生原因:当一个类已经某个类加载器加载到内存中了,此时另一个类加载器又尝试着动态地从同一个包中加载这个类。

1.3 使用try...catche的注意事项?

  1. 尽量不要捕获类似Exception这样的通用异常,而是应该捕获特定异常。当然更不要捕获Throwable和Error类型的异常。
  2. 不要生吞(swallow)异常
  3. 引起异常的东西最好提前判断
public void readPreferences(String fileName){
    // 如果此处fileName为空,就会导致NPE,就会蒙B,不知道是哪里出错了。所以需要在前面判断一下
    InputStream in = new FileInputStream(fileName);
}
  1. 捕获到的异常不要直接使用e.printStackTrace()打印

这个输出是不受log4j或者logback管理的。是属于标准输出,一般直接输出给了tomcat,由于不受log4j或者logback管理,你就无法控制它的输出位置和格式。如果做日志分析,你无法控制它的输出位置和格式,你就无法分析。

1.4 异常带来的性能开销?

1、try-catch 代码段会产生额外的性能开销,换个角度说,它往往会影响JVM对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的 try 包住整段的代码;更不要利用异常控制代码流程,这远比我们通常意义上的条件语句(if/else、switch)要低效。

2、Java每实例化一个 Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个开销可就不能被忽略了。

1.5 对于异常处理编程,不同的编程范式也会影响到异常处理策略,比如,现在非常火热的反应式编程(Reactive Stream),因为其本身是异步、基于事件机制的,所以出现异常情况,决不能简单抛出去;另外,由于代码堆栈不再是同步调用那种垂直的结构,这里的异常处理和日志需要更加小心,我们看到的往往是特定executor的堆栈,而不是业务方法调用关系。对于这种情况,你有什么好的办法吗?

我之前做过一个项目,里面使用到了异步进行调来调去,如果里面抛出异常就会导致一下两个问题:

  1. 如果同时启动2个任务,就会导致打印的日志非常乱,不知道是谁打印的。

使其全程携带一个唯一变量(方法参数),打印日志的时候同时输出。

  1. 由于新建了一个线程,当抛出异常的时候异常栈不包括调用它的那个(因为已经切换线程了)。

可以将调用的方法传入(入参)给被调用者,也可以直接修改新建的线程名。

2、final、finally、 finalize有什么不同?

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

finally则是Java保证重点代码一定要被执行的一种机制。我们可以使用try-finally或者try-catch-finally来进行类似关闭JDBC连接、保证unlock锁等动作。

finalize是基础类java.lang.Object的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize机制现在已经不推荐使用,并且在JDK 9开始被标记为deprecated。

注意:下面的代码不会被执行的。

try {
  // do something
  System.exit(1);
} finally{
  System.out.println(“Print from finally”);
}

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

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

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

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

3.1 直接拼接字符串在jdk8和9中的优化?

String str = "1" + "a" + ...

在JDK 8中,字符串拼接操作会自动被javac转换为StringBuilder操作,而在JDK 9里面则是因为Java 9为了更加统一字符串操作优化,提供了StringConcatFactory,作为一个统一的入口。javac自动生成的代码,虽然未必是最优化的,但普通场景也足够了,你可以酌情选择。

3.2 字符串缓存?

在常见的应用系统中,基本上堆空间中平均25%的对象是字符串,并且其中约半数是重复的。如果能避免创建重复字符串,可以有效降低内存消耗和对象创建开销。

String在Java 6以后提供了intern()方法,目的是提示JVM把相应字符串缓存起来,以备重复使用。在我们创建字符串对象并调用intern()方法的时候,如果已经有缓存的字符串,就会返回缓存里的实例,否则将其缓存起来。一般来说,JVM会将所有的类似“abc”这样的文本字符串,或者字符串常量之类缓存起来。

看起来很不错是吧?但实际情况估计会让你大跌眼镜。一般使用Java 6这种历史版本,并不推荐大量使用intern,为什么呢?魔鬼存在于细节中,被缓存的字符串是存在所谓PermGen里的,也就是臭名昭著的“永久代”,这个空间是很有限的,也基本不会被FullGC之外的垃圾收集照顾到。所以,如果使用不当,OOM就会光顾。

在后续版本中,这个缓存被放置在堆中,这样就极大避免了永久代占满的问题,甚至永久代在JDK 8中被MetaSpace(元数据区)替代了。而且,默认缓存大小也在不断地扩大中,从最初的1009,到7u40以后被修改为60013。

Intern是一种显式地排重机制,但是它也有一定的副作用,因为需要开发者写代码时明确调用,一是不方便,每一个都显式调用是非常麻烦的;另外就是我们很难保证效率,应用开发阶段很难清楚地预计字符串的重复情况,有人认为这是一种污染代码的实践。

幸好在Oracle JDK 8u20之后,推出了一个新的特性,也就是G1 GC下的字符串排重。它是通过将相同数据的字符串指向同一份数据来做到的,是JVM底层的改变,并不需要Java类库做什么修改。

注意这个功能目前是默认关闭的,你需要使用下面参数开启,并且记得指定使用G1 GC:

-XX:+UseStringDeduplication

3.3 String自身的演化

如果你仔细观察过Java的字符串,在历史版本中,它是使用char数组来存数据的,这样非常直接。但是Java中的char是两个bytes大小,拉丁语系语言的字符,根本就不需要太宽的char,这样无区别的实现就造成了一定的浪费。密度是编程语言平台永恒的话题,因为归根结底绝大部分任务是要来操作数据的。

在Java 9中,我们引入了Compact Strings的设计,对字符串进行了大刀阔斧的改进。将数据存储方式从char数组,改变为一个byte数组加上一个标识编码的所谓coder。

在通用的性能测试和产品实验中,我们能非常明显地看到紧凑字符串带来的优势,即更小的内存占用、更快的操作速度。

4、Java反射机制,动态代理是基于什么原理?

反射可以让我们在运行时动态创建对象、获取类中声明的属性和方法。

实现动态代理的方式很多,比如JDK自身提供的动态代理,就是主要利用了反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似ASM、cglib(基于ASM)、Javassist等。

rpc调用和aop编程都使用了动态代理的技术。

4.1 Java是动态类型语言还是静态类型语言?

动态类型和静态类型就是其中一种分类角度,简单区分就是语言类型信息是在运行时检查,还是编译期检查。

Java是静态的强类型语言,但是因为提供了类似反射等机制,也具备了部分动态类型语言的能力。

4.2 什么时候使用反射?Jdbc为什么要用反射?

简单工厂,让生成的对象可配置,降低代码依赖(解耦)。

4.3 反射的使用场景

  1. 自动生成get、set方法
  2. 绕过API访问控制。我们日常开发时可能被迫要调用内部API去做些事情,比如,自定义的高性能NIO框架需要显式地释放DirectBuffer,使用反射绕开限制是一种常见办法。

4.4 动态代理解决的问题

代理可以看作是对调用目标的一个包装,这样我们对目标代码的调用不是直接发生的,而是通过代理完成。

通过代理可以让调用者与实现者之间解耦。比如进行RPC调用,框架内部的寻址、序列化、反序列化等,对于调用者往往是没有太大意义的,通过代理,可以提供更加友善的界面。

增加代码的扩展性

4.5 JDK代理与ASM代理如何选用?

JDK Proxy的优势:

  • 最小化依赖关系,减少依赖意味着简化开发和维护,JDK本身的支持,可能比cglib更加可靠。
  • 平滑进行JDK版本升级,而字节码类库通常需要进行更新以保证在新版Java上能够使用。
  • 代码实现简单。

基于类似cglib框架的优势:

  • 有的时候调用目标可能不便实现额外接口,从某种角度看,限定调用者实现接口是有些侵入性的实践,类似cglib动态代理就没有这种限制。
  • 只操作我们关心的类,而不必为其他相关类增加工作量。
  • 高性能。

5、int和Integer有什么区别?谈谈Integer的值缓存范围。

基本数据类型不是对象,本着万物皆对象的理念,java为我们提供了基本类型的包装类,并提供一些方法。而且原始数据类型和Java泛型并不能配合使用

值缓存的范围:-128~127

5.1 自动拆装箱是什么?发生在编译阶段还是运行时?

装箱:基本 -> 包装

拆箱:包装 -> 基本

自动装箱实际上算是一种语法糖。什么是语法糖?可以简单理解为Java平台为我们自动进行了一些转换,保证不同的写法在运行时等价,它们发生在编译阶段,也就是生成的字节码是一致的。**

像前面提到的整数,javac替我们自动把装箱转换为Integer.valueOf(),把拆箱替换为Integer.intValue()。

自动拆装箱发生在编译阶段

5.2 其它包装类型有缓存机制吗?

Boolean,缓存了true/false对应实例,确切说,只会返回两个常量实例Boolean.TRUE/FALSE。

Short,同样是缓存了-128到127之间的数值。

Byte,数值有限,所以全部都被缓存。

Character,缓存范围’\u0000’ 到 ‘\u007F’。

5.3 使用拆装箱注意点?

建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,创建10万个Java对象和10万个整数的开销可不是一个数量级的,不管是内存使用还是处理速度,光是对象头的空间占用就已经是数量级的差距了。

5.4 既然都有了包装类,那么基本数据类型还有必要存在吗?

在性能敏感的场合,创建10万个Java对象和10万个整数的开销可不是一个数量级的

5.5 int与Integer之间的比较

1、Integer是int的包装类,int则是java的一种基本数据类型 
2、Integer变量必须实例化后才能使用,而int变量不需要 
3、Integer实际是对象的引用,当new一个Integer时,实际上是生成一个指针指向此对象;而int则是直接存储数据值 
4、Integer的默认值是null,int的默认值是0

延伸: 
关于Integer和int的比较 
1、由于Integer变量实际上是对一个Integer对象的引用,所以两个通过new生成的Integer变量永远是不相等的(因为new生成的是两个对象,其内存地址不同)。

Integer i = new Integer(100);
Integer j = new Integer(100);
System.out.print(i == j); //false

2、Integer变量和int变量比较时,只要两个变量的值是向等的,则结果为true(因为包装类Integer和基本数据类型int比较时,java会自动拆包装为int,然后进行比较,实际上就变为两个int变量的比较)

Integer i = new Integer(100);
int j = 100;
System.out.print(i == j); //true

3、非new生成的Integer变量和new Integer()生成的变量比较时,结果为false。(因为非new生成的Integer变量指向的是java常量池中的对象,而new Integer()生成的变量指向堆中新建的对象,两者在内存中的地址不同)

Integer i = new Integer(100);
Integer j = 100;
System.out.print(i == j); //false

4、对于两个非new生成的Integer对象,进行比较时,如果两个变量的值在区间-128到127之间,则比较结果为true,如果两个变量的值不在此区间,则比较结果为false

Integer i = 100;
Integer j = 100;
System.out.print(i == j); //true

Integer i = 128;
Integer j = 128;
System.out.print(i == j); //false

对于第4条的原因: 
java在编译Integer i = 100 ;时,会翻译成为Integer i = Integer.valueOf(100);,而java API中对Integer类型的valueOf的定义如下:

public static Integer valueOf(int i){
    assert IntegerCache.high >= 127;
    if (i >= IntegerCache.low && i <= IntegerCache.high){
        return IntegerCache.cache[i + (-IntegerCache.low)];
    }
    return new Integer(i);
}

java对于-128到127之间的数,会进行缓存,Integer i = 127时,会将127进行缓存,下次再写Integer j = 127时,就会直接从缓存中取,就不会new了

6、Vector、ArrayList、LinkedList有何区别?

这三者都是实现集合框架中的List,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因为具体的设计区别,在行为、性能、线程安全等方面,表现又有很大不同。

Vector是Java早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。

ArrayList是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与Vector近似,ArrayList也是可以根据需要调整容量,不过两者的调整逻辑有所区别,Vector在扩容时会提高1倍,而ArrayList则是增加50%。(插入元素时间复杂度:o(1)~o(n),查找:o(1))

LinkedList顾名思义是Java提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的。(插入元素时间复杂度:o(1),查找:o(1)~o(n))

6.1 集合们的基本特征和典型使用场景,以Set的几个实现为例:

TreeSet支持自然顺序访问(通过比较器进行排序),但是添加、删除、包含等操作要相对低效(log(n)时间)。

HashSet则是利用哈希算法,理想情况下,如果哈希散列正常,可以提供常数时间的添加、删除、包含等操作,但是它不保证有序。

LinkedHashSet,内部构建了一个记录插入顺序的双向链表,因此提供了按照插入顺序遍历的能力,与此同时,也保证了常数时间的添加、删除、包含等操作,这些操作性能略低于HashSet,因为需要维护链表的开销。

在遍历元素时,HashSet性能受自身容量影响,所以初始化时,除非有必要,不然不要将其背后的HashMap容量设置过大。而对于LinkedHashSet,由于其内部链表提供的方便,遍历性能只和元素多少有关系。

6.2

Java面试题总结(下)插图1

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

Hashtable是早期Java类库提供的一个哈希表实现,本身是同步的,不支持null键和值,由于同步导致的性能开销,所以已经很少被推荐使用。

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

TreeMap则是基于红黑树的一种提供顺序访问的Map,和HashMap不同,它的get、put、remove之类操作都是O(log(n))的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序来判断。

7.1 日常中使用哪种map最多?使用它有什么要求?

大部分使用Map的场景,通常就是放入、访问或者删除,而对顺序没有特别要求,HashMap在这种情况下基本是最好的选择。HashMap的性能表现非常依赖于哈希码的有效性,请务必掌握hashCode和equals的一些基本约定,比如:

  1. equals相等,hashCode一定要相等。
  2. 重写了hashCode也要重写equals。
  3. hashCode需要保持一致性,状态改变返回的哈希值仍然要一致。
  4. equals的对称、反射、传递等特性。

7.2 HashMap内部实现基本点分析?HashMap内部实现基本点分析?树化 ?

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

Java面试题总结(下)插图2

7.2.1 初始化

从构造函数的实现来看,这个表格(数组)似乎并没有在最初就初始化好,仅仅设置了一些初始值而已。

public HashMap(int initialCapacity, float loadFactor){  
    // ... 
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

所以,我们深刻怀疑,HashMap也许是按照lazy-load原则,在首次使用时被初始化(拷贝构造函数除外,我这里仅介绍最通用的场景)。既然如此,我们去看看put方法实现,似乎只有一个putVal的调用:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

看来主要的秘密似乎藏在putVal里面,到底有什么秘密呢?为了节省空间,我这里只截取了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()).length;
    if ((p = tab[i = (n - 1) & hash]) == ull)
        tab[i] = newNode(hash, key, value, nll);
    else {
        // ...
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first 
           treeifyBin(tab, hash);
        //  ... 
     }
}

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

  1. 如果表格是null,resize方法会负责初始化它,这从tab = resize()可以看出。

  2. resize方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)。

  3. 在放置新的键值对的过程中,如果发生下面条件,就会发生扩容。

if (++size > threshold)
    resize();
  1. 具体键值对在哈希表中的位置(数组index)取决于下面的位运算:
i = (n - 1) & hash

仔细观察哈希值的源头,我们会发现,它并不是key本身的hashCode,而是来自于HashMap内部的另外一个hash方法。注意,为什么这里需要将高位数据移位到低位进行异或运算呢?这是因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。**

static final int hash(Object kye) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16;
}
  1. 我前面提到的链表结构(这里叫bin),会在达到一定门限值时,发生树化

7.2.2 resize方法

final Node<K,V>[] resize() {
    // ...
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&
                oldCap >= DEFAULT_INITIAL_CAPAITY)
        newThr = oldThr << 1; // double there
       // ... 
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {  
        // zero initial threshold signifies using defaultsfults
        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 : Integer.MAX_VALUE);
    }
    threshold = neThr;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];
    table = n;
    // 移动到新的数组结构e数组结构 
   }

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

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

7.2.3 容量、负载因子

容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。

既然容量和负载因子这么重要,我们在实践中应该如何选择呢?

如果能够知道HashMap要存取的键值对数量,可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:

负载因子 * 容量 > 元素数量

而对于负载因子,我建议:

  1. 如果没有特别需求,不要轻易进行更改,因为JDK自身的默认负载因子是非常符合通用场景的需求的。

  2. 如果确实需要调整,建议不要设置超过0.75的数值,因为会显著增加冲突,降低HashMap的性能。

  3. 如果使用太小的负载因子,按照上面的公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响。

7.2.4 树化

树化改造,对应逻辑主要在putVal和treeifyBin中。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        //树化改造逻辑
    }
}

上面是精简过的treeifyBin示意,综合这两个方法,树化改造的逻辑就非常清晰了,可以理解为,当bin的数量大于TREEIFY_THRESHOLD时:

  • 如果容量小于MIN_TREEIFY_CAPACITY,只会进行简单的扩容。
  • 如果容量大于MIN_TREEIFY_CAPACITY ,则会进行树化改造。

那么,为什么HashMap要树化呢?

本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性能

而在现实世界,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务器端CPU大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过类似攻击事件。

7.3 解决哈希冲突的常用方法?

https://www.jianshu.com/p/4d3cb99d7580

8、ConcurrentHashMap如何实现高效地线程安全?

8.1 JDK7之前

早期ConcurrentHashMap,其实现是基于:

  1. 分离锁,也就是将内部进行分段(Segment),里面则是HashEntry的数组,和HashMap类似,哈希相同的条目也是以链表形式存放。其中Segment继承ReentrantLock用来充当锁的角色

  2. HashEntry内部使用volatile的value字段来保证可见性,也利用了不可变对象的机制以改进利用Unsafe提供的底层能力,比如volatile access,去直接完成部分操作,以最优化性能,毕竟Unsafe中的很多操作都是JVM intrinsic优化过的。

早期ConcurrentHashMap内部结构的示意图,其核心是利用分段设计,在进行并发操作的时候,只需要锁定相应段,这样就有效避免了类似Hashtable整体同步的问题,大大提高了性能。

Java面试题总结(下)插图3

在构造的时候,Segment的数量由所谓的concurrentcyLevel决定,默认是16,也可以在相应构造函数直接指定。注意,Java需要它是2的幂数值,如果输入是类似15这种非幂值,会被自动调整到16之类2的幂数值。

具体情况,我们一起看看一些Map基本操作的源码,这是JDK 7比较新的get代码。针对具体的优化部分,为方便理解,我直接注释在代码段里,get操作需要保证的是可见性,所以并没有什么同步逻辑。

public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    int h = hash(key.hashCode());
    //利用位操作替换普通数学运算
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 以Segment为单位,进行定位
    // 利用Unsafe直接进行volatile access
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        //省略
        }
    return null;
}

而对于put操作,首先是通过二次哈希避免哈希冲突,然后以Unsafe调用方式,直接获取相应的Segment,然后进行线程安全的put操作:

 public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    // 二次哈希,以保证数据的分散性,避免哈希冲突
    int hash = hash(key.hashCode());
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
            (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}

其核心逻辑实现在下面的内部方法中:

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // scanAndLockForPut会去查找是否有key相同Node
    // 无论如何,确保获取锁
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                K k;
                // 更新已有value...
            }
            else {
                // 放置HashEntry到特定位置,如果超过阈值,进行rehash
                // ...
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

所以,从上面的源码清晰的看出,在进行并发写操作时:

ConcurrentHashMap会获取再入锁,以保证数据一致性,Segment本身就是基于ReentrantLock的扩展实现,所以,在并发修改期间,相应Segment是被锁定的

在最初阶段,进行重复性的扫描,以确定相应key值是否已经在数组里面,进而决定是更新还是放置操作,你可以在代码里看到相应的注释。重复扫描、检测冲突是ConcurrentHashMap的常见技巧。

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

另外一个Map的size方法同样需要关注,它的实现涉及分离锁的一个副作用

试想,如果不进行同步,简单的计算所有Segment的总值,可能会因为并发put,导致结果不准确,但是直接锁定所有Segment进行计算,就会变得非常昂贵。其实,分离锁也限制了Map的初始化等操作。

所以,ConcurrentHashMap的实现是通过重试机制(RETRIES_BEFORE_LOCK,指定重试次数2),来试图获得可靠值。如果没有监控到发生变化(通过对比Segment.modCount),就直接返回,否则获取锁进行操作。

每个segment都有一个modCount变量保存修改次数,segment被更新时modCount会+1。所以在size()计算大小时,会判断每个segment的modCount是否有变化,如果有变化,如果有变化则重新计算,当然忍耐是有限度的,重试3次后就会将所有segment锁住,计算完size后就会释放锁。

8.2 Java 8和之后的版本中,ConcurrentHashMap发生了哪些变化呢?

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

先看看现在的数据存储内部实现,我们可以发现Key是final的,因为在生命周期中,一个条目的Key发生变化是不可能的;与此同时val,则声明为volatile,以保证可见性。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    // … 
}

8.2.1 源码中基本概念

table:默认为null,初始化发生在第一次插入操作,默认大小为16的数组,用来存储Node节点数据,扩容时大小总是2的幂次方。

nextTable:默认为null,扩容时新生成的数组,其大小为原数组的两倍。

sizeCtl:默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来。

  • -1代表table正在初始化
  • -N表示有N-1个线程正在进行扩容操作

Node:保存key,value及key的hash值的数据结构。

ForwardingNode:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用。

final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null, null);
        this.nextTable = tab;
    }
}

只有table发生扩容的时候,ForwardingNode才会发挥作用,作为一个占位符放在table中表示当前节点为null或则已经被移动。

8.2.2 初始化

和HashMap一样,table初始化操作会延缓到第一次put行为。但是put是可以并发执行的,那么是如何实现table只初始化一次的?

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 如果一个线程发现sizeCtl<0,意味着另外的线程执行CAS操作成功,当前线程只需要让出cpu时间片
        if ((sc = sizeCtl) < 0) 
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

sizeCtl默认为0,如果ConcurrentHashMap实例化时有传参数,sizeCtl会是一个2的幂次方的值。所以执行第一次put操作的线程会执行Unsafe.compareAndSwapInt方法修改sizeCtl为-1,有且只有一个线程能够修改成功,其它线程通过Thread.yield()让出CPU时间片等待table初始化完成。

8.2.3 put操作

假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作,具体实现如下。

final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();

    // 计算hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // 将高位数据移位到低位进行异或运算,算出table中的位置
        // 然后通过cas的getObjectVolatile方法判断这个位置是不是空的
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 利用CAS操作初始化这个位置,如果cas操作成功,则结束,否则进行自旋等待下一次操作。
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break; 
        }
        // 如果f的hash值为-1,说明当前f是ForwardingNode节点,意味有其它线程正在扩容,则一起进行扩容操作。
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else if (onlyIfAbsent // 不加锁,进行检查
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                 && (fv = f.val) != null)
            return fv;
        else {
            V oldVal = null;
            synchronized (f) {
                    // 在节点f上进行同步,节点插入之前,再次利用tabAt(tab, i) == f判断,防止被其它线程修改。
                    // 如果f.hash >= 0,说明f是链表结构的头结点,遍历链表,如果找到对应的node节点,则修改value,否则在链表尾部加入节点。
                    // 如果f是TreeBin类型节点,说明f是红黑树根节点,则在树结构上遍历元素,更新或增加节点。
                }
            }
            // 如果链表中节点数binCount >= TREEIFY_THRESHOLD(默认是8),则把链表转化为红黑树结构。
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 检查当前容量是否需要进行扩容。
    addCount(1L, binCount);
    return null;
}

8.2.4 size方法

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

我们发现,虽然思路仍然和以前类似,都是分而治之的进行计数,然后求和处理,但实现却基于一个奇怪的CounterCell。

static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

对于CounterCell的操作,是基于java.util.concurrent.atomic.LongAdder进行的,是一种JVM利用空间换取更高效率的方法,利用了Striped64内部的复杂逻辑。这个东西非常小众,大多数情况下,建议还是使用AtomicLong,足以满足绝大部分应用的性能需求。

部分参考于:https://www.jianshu.com/p/c0642afe03e0

9、Java提供了哪些IO方式? NIO如何实现多路复用?

Java IO方式有很多种,基于不同的IO抽象模型和交互方式,可以进行简单区分。

BIO:同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。

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

java.io包的好处是代码比较简单、直观,缺点则是IO效率和扩展性存在局限性,容易成为应用性能的瓶颈。

很多时候,人们也把java.net下面提供的部分网络API,比如Socket、ServerSocket、> HttpURLConnection也归类到同步阻塞IO类库,因为网络通信同样是IO行为。

NIO:同时支持阻塞与非阻塞模式,但主要是使用同步非阻塞IO

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

NIO2:异步非阻塞I/O模型。

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

9.1 序列化与IO操作?

序列化(Serialization):将对象的状态信息转换为可以存储或传输的形式的过程。

IO操作:从文件、socket中读写数据的操作。

java中的对象需要进行序列化之后,才能进行IO操作。

9.2 阻塞、非阻塞和同步、异步的区别?

同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;

异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系。

在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如ServerSocket新连接建立完毕,或数据读取、写入操作完成。

非阻塞则是不管IO操作是否结束,直接返回,相应操作在后台继续处理。

阻塞、非阻塞说的是调用者,同步、异步说的是被调用者。

0708fix:

同步:在哪等着事情做完

异步:去干别的事情了,事情做完了通知我们

不知道是不是对的

// 异步阻塞
Future f = xxx;
f.get();

// 异步非阻塞
Future f = xxx;
f.addListener(yyy);

// 同步非阻塞
Future f = xxx;
while (f.isdone()) {
}

9.3 InputStream/OutputStream和Reader/Writer的关系和区别?

输入流、输出流(InputStream/OutputStream)是用于读取或写入字节的,例如操作图片文件。

Reader/Writer则是用于操作字符,增加了字符编解码等功能,适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节,不管是网络通信还是文件读取,Reader/Writer相当于构建了应用逻辑和原始数据之间的桥梁。

BufferedOutputStream等带缓冲区的实现,可以避免频繁的磁盘读写,进而提高IO处理效率。这种设计利用了缓冲区,将批量数据进行一次操作,但在使用中千万别忘了flush。

9.4 为什么大多数IO工具类都实现了Closeable接口?

Java面试题总结(下)插图4

因为需要进行资源的释放。比如,打开FileInputStream,它就会获取相应的文件描述符(FileDescriptor),需要利用try-with-resources、 try-finally等机制保证FileInputStream被明确关闭,进而相应文件描述符也会失效,否则将导致资源无法被释放。利用专栏前面的内容提到的Cleaner或finalize机制作为资源释放的最后把关,也是必要的。

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 —— wiki

9.5 Java NIO中的概念?

Buffer,高效的数据容器,除了布尔类型,所有原始数据类型都有相应的Buffer实现。

Channel,类似在Linux之类操作系统上看到的文件描述符,是NIO中被用来支持批量式IO操作的一种抽象。

File或者Socket,通常被认为是比较高层次的抽象,而Channel则是更加操作系统底层的一种抽象,这也使得NIO得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct Memory Access)等。不同层次的抽象是相互关联的,我们可以通过Socket获取Channel,反之亦然。

Selector,是NIO实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在Selector上的多个Channel中,是否有Channel处于就绪状态,进而实现了单线程对多Channel的高效管理

在Linux上依赖于epoll,windows上依赖于epoll。

Chartset,提供Unicode字符串定义,NIO也提供了相应的编解码器等,例如,通过下面的方式进行字符串到ByteBuffer的转换:

Charset.defaultCharset().encode("Hello world!"));

9.6 NIO能解决什么问题?

以聊天室为例:

如果采用每来一个用户就新建一个线程的方式:

由于Java语言目前的线程实现是比较重量级的,启动或者销毁一个线程是有明显开销的,每个线程都有单独的线程栈等结构,需要占用非常明显的内存,所以,每一个Client启动一个线程似乎都有些浪费。

那么我们引入线程池:

如果连接数并不是非常多,只有最多几百个连接的普通应用,这种模式往往可以工作的很好。但是,如果连接数量急剧上升,这种实现方式就无法很好地工作了,因为线程上下文切换开销会在高并发时变得很明显,这是同步阻塞方式的低扩展性劣势。

引入IO多路复用:

在前面两个样例中,IO都是同步阻塞模式,所以需要多线程以实现多任务处理。而NIO则是利用了单线程轮询事件的机制,通过高效地定位就绪的Channel,来决定做什么,仅仅select阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。

IO多路复用:通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。

9.7 NIO多路复用的局限性是什么呢?

1、如果并发并不高,一直在轮询的等待请求,非常浪费CPU资源

2、处理请求的操作不能太长

10、Java有几种文件拷贝方式?哪一种最高效?

利用java.io类库,直接为源文件构建一个FileInputStream读取,然后再为目标文件构建一个FileOutputStream,完成写入工作。

public static void copyFileByStream(File source, File dest) throws IOException {
    try (InputStream is = new FileInputStream(source);
        OutputStream os = new FileOutputStream(dest);){
        byte[] buffer = new byte[1024];
        int length;
        while ((length = is.read(buffer)) > 0) {
            os.write(buffer, 0, length);
        }
    }
}

利用java.nio类库提供的transferTo或transferFrom方法实现。

public static void copyFileByChannel(File source, File dest) throws IOException {
    try (FileChannel sourceChannel = new FileInputStream(source).getChannel();
         FileChannel targetChannel = new FileOutputStream(dest).getChannel()) {

        for (long count = sourceChannel.size(); count > 0; ) {
            long transferred = sourceChannel.transferTo(sourceChannel.position(), count, targetChannel);
            sourceChannel.position(sourceChannel.position() + transferred);
            count -= transferred;
        }
    }
}

对于Copy的效率,这个其实与操作系统和配置等情况相关,总体上来说,NIO transferTo/From的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。

10.1 2种拷贝实现机制?

计算机操作系统通常将虚拟内存分离为内核空间和用户空间。这种分离主要用于提供内存保护和硬件保护,以防止恶意或错误的软件行为。操作系统内核、硬件驱动等运行在内核态空间,具有相对高的特权;而用户态空间,则是给普通应用和服务使用。

10.1.1 使用BIO的方式

当我们使用输入输出流进行读写时,实际上是进行了多次上下文切换,比如应用读取数据时,先在内核态将数据从磁盘读取到内核缓存,再切换到用户态将数据从内核缓存读取到用户缓存。写入操作也是类似,仅仅是步骤相反。

Java面试题总结(下)插图5

这种方式会带来一定的额外开销,可能会降低IO效率。

10.1.2 使用NIO的方式

NIO transferTo的实现方式,在Linux和Unix上,则会使用到零拷贝技术,数据传输并不需要用户态参与,省去了上下文切换的开销和不必要的内存拷贝,进而可能提高应用拷贝性能。注意,transferTo不仅仅是可以用在文件拷贝中,与其类似的,例如读取磁盘文件,然后进行Socket发送,同样可以享受这种机制带来的性能和扩展性提高。

Java面试题总结(下)插图6

10.2 。。。

[其它](file:///Volumes/%E9%91%AB%E5%93%A5%E6%A3%92%E6%A3%92%E5%93%92%E7%9A%84%E7%A7%BB%E5%8A%A8%E7%A1%AC%E7%9B%98/%E6%95%99%E7%A8%8B/Java%E6%A0%B8%E5%BF%83%E6%8A%80%E6%9C%AF36%E8%AE%B2/%E7%AC%AC12%E8%AE%B2.Java%E6%9C%89%E5%87%A0%E7%A7%8D%E6%96%87%E4%BB%B6%E6%8B%B7%E8%B4%9D%E6%96%B9%E5%BC%8F%EF%BC%9F%E5%93%AA%E4%B8%80%E7%A7%8D%E6%9C%80%E9%AB%98%E6%95%88%EF%BC%9F.html)

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

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

接口的职责也不仅仅限于抽象方法的集合,其实有各种不同的实践。有一类没有任何方法的接口,通常叫作Marker Interface,顾名思义,它的目的就是为了声明某些东西,比如我们熟知的Cloneable、Serializable等。这种用法,也存在于业界其他的Java产品代码中。

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

11.1 面向对象设计

封装的目的是隐藏事务内部的实现细节,以便提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。我们在日常开发中,因为无意间暴露了细节导致的难缠bug太多了,比如在多线程环境暴露内部状态,导致的并发修改问题。从另外一个角度看,封装这种隐藏,也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。

继承代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。

多态,你可能立即会想到重写(override)和重载(overload)、向上转型。简单说,重写是父子类中相同名字和参数的方法,不同的实现;重载则是相同名字的方法,但是不同的参数,本质上这些方法签名(由方法名称和一个参数列表组成)是不一样的,为了更好说明,请参考下面的样例代码:

public int doSomething() {
    return 0;
}
// 输入参数不同,意味着方法签名不同,重载的体现
public int doSomething(List<String> strs) {
    return 0;
}
// return类型不一样,编译不能通过
public short doSomething() {
    return 0;
}

11.2 SOLID设计原则

首字母 指代 概念
S 单一功能原则 认为对象应该仅具有一种单一功能的概念,在程序设计中如果发现某个类承担着多种义务,可以考虑进行拆分。
O 开闭原则 认为“软件体应该是对于扩展开放的,但是对于修改封闭的”的概念。
L 里氏替换原则 认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”的概念。参考契约式设计。
I 接口隔离原则 认为“多个特定客户端接口要好于一个宽泛用途的接口”[5] 的概念。
D 依赖反转原则 认为一个方法应该遵从“依赖于抽象而不是一个实例”[5] 的概念。 依赖注入是该原则的一种实现方式。

12、谈谈你知道的设计模式?

创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式(Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模式(ProtoType)。

结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式(Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)等。

行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)。

12.1 如何识别装饰器模式?

识别类设计特征来进行判断,也就是其类构造函数以相同的抽象类或者接口为输入参数

因为装饰器模式本质上是包装同类型实例,我们对目标对象的调用,往往会通过包装类覆盖过的方法,迂回调用被包装的实例,这就可以很自然地实现增加额外逻辑的目的,也就是所谓的“装饰”。

例如,BufferedInputStream经过包装,为输入流过程增加缓存,类似这种装饰器还可以多次嵌套,不断地增加不同层次的功能。

public BufferedInputStream(InputStream in)

我在下面的类图里,简单总结了InputStream的装饰模式实践。

Java面试题总结(下)插图7

12.2 建造者模式

使用构建器模式,可以比较优雅地解决构建复杂对象的麻烦,这里的“复杂”是指类似需要输入的参数组合较多,如果用构造函数,我们往往需要为每一种可能的输入参数组合实现相应的构造函数,一系列复杂的构造函数会让代码阅读性和可维护性变得很差。

HttpRequest request = HttpRequest.newBuilder(new URI(uri))
                     .header(headerAlice, valueAlice)
                     .headers(headerBob, value1Bob,
                      headerCarl, valueCarl,
                      headerBob, value2Bob)
                     .GET()
                     .build();

12.3 Spring等框架中使用了哪些模式?

BeanFactory和ApplicationContext应用了工厂模式。

在Bean的创建中,Spring也为不同scope定义的对象,提供了单例和原型等模式实现。

原型模式是创建型模式的一种,其特点在于通过「复制」一个已经存在的实例来返回新的实例,而不是新建实例。 被复制的实例就是我们所称的「原型」,这个原型是可定制的。 原型模式多用于创建复杂的或者耗时的实例,因为这种情况下,复制一个已经存在的实例使程序运行更高效;或者创建值相等,只是命名不一样的同类数据。 —— wiki

AOP领域则是使用了代理模式、装饰器模式、适配器模式等。

各种事件监听器,是观察者模式的典型应用。

类似JdbcTemplate等则是应用了模板模式。

13、一个线程两次调用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)状态,但是不论如何,都是不可以再次启动的。

Java面试题总结(下)插图8

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

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

  • 提供了比synchronized更加高级的各种同步结构,包括CountDownLatch、CyclicBarrier、Semaphore等,可以实现更加丰富的多线程操作,比如利用Semaphore作为资源控制器,限制同时进行工作的线程数量
  • 各种线程安全的容器,比如最常见的ConcurrentHashMap、有序的ConcunrrentSkipListMap,或者通过类似快照机制,实现线程安全的动态数组CopyOnWriteArrayList等。
  • 各种并发队列实现,如各种BlockedQueue实现,比较典型的ArrayBlockingQueue、 SynchorousQueue或针对特定场景的PriorityBlockingQueue等。
  • 强大的Executor框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器。

14.1 Map的并发容器

如果我们的应用侧重于Map放入或者获取的速度,而不在乎顺序,大多推荐使用ConcurrentHashMap,反之则使用ConcurrentSkipListMap;如果我们需要对大量数据进行非常频繁地修改,ConcurrentSkipListMap也可能表现出优势。

在传统的Map中,普通无顺序场景选择HashMap,有顺序场景则可以选择类似TreeMap等,但是为什么并发容器里面没有ConcurrentTreeMap呢?

这是因为TreeMap要实现高效的线程安全是非常困难的,它的实现基于复杂的红黑树。为保证访问效率,当我们插入或删除节点时,会移动节点进行平衡操作,这导致在并发场景中难以进行合理粒度的同步。而SkipList结构则要相对简单很多,通过层次结构提高访问速度,虽然不够紧凑,空间使用有一定提高(O(nlogn)),但是在增删元素时线程安全的开销要好很多。

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

。。。

LinkedList是Deque

java.util.concurrent包提供的容器(Queue、List、Set)、Map,从命名上可以大概区分为Concurrent*、CopyOnWrite和Blocking等三类。

  • Concurrent*:lock-free机制(无锁机制,一般采用CAS)
  • CopyOnWrite*:采用空间换取
  • Blocking*:基于锁(读锁、写锁)

16、Executor框架设计?

Java面试题总结(下)插图9

Executor是一个基础的接口,其初衷是将任务提交和任务执行细节解耦,它提供了唯一方法。

void execute(Runnable command);

[。。。]
(
file:///Volumes/%E9%91%AB%E5%93%A5%E6%A3%92%E6%A3%92%E5%93%92%E7%9A%84%E7%A7%BB%E5%8A%A8%E7%A1%AC%E7%9B%98/%E6%95%99%E7%A8%8B/Java%E6%A0%B8%E5%BF%83%E6%8A%80%E6%9C%AF36%E8%AE%B2/%E7%AC%AC21%E8%AE%B2.Java%E5%B9%B6%E5%8F%91%E7%B1%BB%E5%BA%93%E6%8F%90%E4%BE%9B%E7%9A%84%E7%BA%BF%E7%A8%8B%E6%B1%A0%E6%9C%89%E5%93%AA%E5%87%A0%E7%A7%8D%EF%BC%9F%20%E5%88%86%E5%88%AB%E6%9C%89%E4%BB%80%E4%B9%88%E7%89%B9%E7%82%B9%EF%BC%9F.html)

17、AQS

[。。。](

file:///Volumes/%E9%91%AB%E5%93%A5%E6%A3%92%E6%A3%92%E5%93%92%E7%9A%84%E7%A7%BB%E5%8A%A8%E7%A1%AC%E7%9B%98/%E6%95%99%E7%A8%8B/Java%E6%A0%B8%E5%BF%83%E6%8A%80%E6%9C%AF36%E8%AE%B2/%E7%AC%AC22%E8%AE%B2.AtomicInteger%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86%E6%98%AF%E4%BB%80%E4%B9%88%EF%BC%9F%E5%A6%82%E4%BD%95%E5%9C%A8%E8%87%AA%E5%B7%B1%E7%9A%84%E4%BA%A7%E5%93%81%E4%BB%A3%E7%A0%81%E4%B8%AD%E5%BA%94%E7%94%A8CAS%E6%93%8D%E4%BD%9C%EF%BC%9F.html)

23~29、33~35、38日后再补

18、Java程序运行在Docker等容器环境有哪些新问题?

Docker中的内存、CPU等资源限制是通过CGroup(Control Group)实现的,早期的JDK版本(8u131之前)并不能识别这些限制,进而会导致一些基础问题:

  • 如果未配置合适的JVM堆和元数据区、直接内存等参数,Java就有可能试图使用超过容器限制的内存,最终被容器OOM kill,或者自身发生OOM。
  • 错误判断了可获取的CPU资源,例如,Docker限制了CPU的核数,JVM就可能设置不合适的GC并行线程数等。

从应用打包、发布等角度出发,JDK自身就比较大,生成的镜像就更为臃肿,当我们的镜像非常多的时候,镜像的存储等开销就比较明显了。

18.1 Docker与虚拟机的区别?

Docker并不是一种完全的虚拟化技术,而更是一种轻量级的隔离技术。

Java面试题总结(下)插图10
Docker与虚拟机的区别

从技术角度,基于namespace,Docker为每个容器提供了单独的命名空间,对网络、PID、用户、IPC通信、文件系统挂载点等实现了隔离。对于CPU、内存、磁盘IO等计算资源,则是通过CGroup进行管理。

Docker仅在类似Linux内核之上实现了有限的隔离和虚拟化,并不是像传统虚拟化软件那样,独立运行一个新的操作系统。

容器虽然省略了虚拟操作系统的开销,实现了轻量级的目标,但也带来了额外复杂性,它的限制对于应用不是透明的,需要用户理解Docker的新行为。所以,有专家曾经说过,“幸运的是Docker没有完全隐藏底层信息,但是不幸的也是Docker没有隐藏底层信息!”

对于Java平台来说,这些未隐藏的底层信息带来了很多意外的困难,主要体现在几个方面:

  1. 容器环境对于计算资源的管理方式是全新的,CGroup作为相对比较新的技术,历史版本的Java显然并不能自然地理解相应的资源限制。
  2. namespace对于容器内的应用细节增加了一些微妙的差异,比如jcmd、jstack等工具会依赖于“/proc//”下面提供的部分信息,但是Docker的设计改变了这部分信息的原有结构,我们需要对原有工具进行修改以适应这种变化。

18.2 从JVM运行机制的角度,为什么这些“沟通障碍”会导致OOM等问题呢?

JVM会在启动时设置默认参数;JVM会大概根据检测到的内存大小,设置最初启动时的堆大小为系统内存的1/64;并将堆最大值,设置为系统内存的1/4。而JVM检测到系统的CPU核数,则直接影响到了Parallel GC的并行线程数目和JIT complier线程数目,甚至是我们应用中ForkJoinPool等机制的并行等级。

这些默认参数,是根据通用场景选择的初始值。但是由于容器环境的差异,Java的判断很可能是基于错误信息而做出的。这就类似,我以为我住的是整栋别墅,实际上却只有一个房间是给我住的

19、你了解Java应用开发中的注入攻击吗?

1、SQL注入攻击

Select * from use_info where username = “input_usr_name” and password = “input_pwd”

-- 但是,用户如果输入的是【"or ""="】,就GG了
-- 或者【;delete xxx】就更完了

2、操作系统命令注入

// 用户输入【123;rm -rf /*】也gg了
Runtime.exec("ls -la" + input_file_name);

3、XSS攻击

19.1 MITM攻击

在密码学和计算机安全领域中是指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方直接对话,但事实上整个会话都被攻击者完全控制。在中间人攻击中,攻击者可以拦截通讯双方的通话并插入新的内容。 —— wiki

20、如何写出安全的Java代码?

以拒绝服务(DoS)攻击为例,有人也称其为“洪水攻击”。最常见的表现是,利用大量机器发送请求,将目标网站的带宽或者其他资源耗尽,导致其无法响应正常用户的请求。

  • 哈希碰撞攻击,就是个典型的例子,对方可以轻易消耗系统有限的CPU和线程资源。从这个角度思考,类似加密、解密、图形处理等计算密集型任务,都要防范被恶意滥用,以免攻击者通过直接调用或者间接触发方式,消耗系统资源。

  • 利用Java构建类似上传文件或者其他接受输入的服务,需要对消耗系统内存或存储的上限有所控制,因为我们不能将系统安全依赖于用户的合理使用。其中特别注意的是涉及解压缩功能时,就需要防范Zip bomb等特定攻击。

  • Java程序中需要明确释放的资源有很多种,比如文件描述符、数据库连接,甚至是再入锁,任何情况下都应该保证资源释放成功,否则即使平时能够正常运行,也可能被攻击者利用而耗尽某类资源,这也算是可能的DoS攻击来源。

// a, b, c都是int类型的数值
// 这段代码的结果可能是错误的,当a超大的话,结果就是错误的。
if (a + b < c) {
    // …
}

// 应该这样写
if (a < c - b) {

}

21、

file:///Volumes/%E9%91%AB%E5%93%A5%E6%A3%92%E6%A3%92%E5%93%92%E7%9A%84%E7%A7%BB%E5%8A%A8%E7%A1%AC%E7%9B%98/%E6%95%99%E7%A8%8B/Java%E6%A0%B8%E5%BF%83%E6%8A%80%E6%9C%AF36%E8%AE%B2/%E7%AC%AC39%E8%AE%B2.%E8%B0%88%E8%B0%88%E5%B8%B8%E7%94%A8%E7%9A%84%E5%88%86%E5%B8%83%E5%BC%8FID%E7%9A%84%E8%AE%BE%E8%AE%A1%E6%96%B9%E6%A1%88%EF%BC%9FSnowflake%E6%98%AF%E5%90%A6%E5%8F%97%E5%86%AC%E4%BB%A4%E6%97%B6%E5%88%87%E6%8D%A2%E5%BD%B1%E5%93%8D%EF%BC%9F.html

22、Spring Bean的生命周期和作用域?

Spring Bean生命周期比较复杂,可以分为创建和销毁两个过程。

创建:

  1. BDRP、BDP..对bd进行处理
  2. 实例化Bean对象。
  3. 设置bean的属性
  4. 如果我们通过各种Aware接口声明了依赖关系,则会注入Bean对容器基础设施层面的依赖。具体包括BeanNameAware、BeanFactoryAware和ApplicationContextAware,分别会注入Bean ID、Bean Factory或者ApplicationContext。
  5. 调用BeanPostProcessor的前置初始化方法postProcessBeforeInitialization。
  6. 如果实现了InitializingBean接口,则会调用afterPropertiesSet方法。
  7. 调用Bean自身定义的init方法。
  8. 调用BeanPostProcessor的后置初始化方法postProcessAfterInitialization。
  9. 创建过程完毕。
Java面试题总结(下)插图11

销毁:

Spring Bean的销毁过程会依次调用DisposableBean的destroy方法和Bean自身定制的destroy方法(@PreDestroy)。

作用域:

Spring Bean有五个作用域,其中最基础的有下面两种:

  • Singleton,这是Spring的默认作用域,也就是为每个IOC容器创建唯一的一个Bean实例。
  • Prototype,针对每个getBean请求,容器都会单独创建一个Bean实例。

从Bean的特点来看,Prototype适合有状态的Bean(有实例变量的对象,可以保存数据,是非线程安全的),而Singleton则更适合无状态的情况。另外,使用Prototype作用域需要经过仔细思考,毕竟频繁创建和销毁Bean是有明显开销的。

如果是Web容器,则支持另外三种作用域:

  • Request,为每个HTTP请求创建单独的Bean实例。
  • Session,很显然Bean实例的作用域是Session范围。
  • GlobalSession,用于Portlet容器,因为每个Portlet有单独的Session,GlobalSession提供一个全局性的HTTP Session。

22.1 IOC、AOP

IOC:控制反转

DI:依赖注入

AOP:面向切面编程

22.2 Spring AOP自身设计和实现的细节?

为啥需要AOP?

切面编程落实到软件工程其实是为了更好地模块化,而不仅仅是为了减少重复代码。通过AOP等机制,我们可以把横跨多个不同模块的代码抽离出来,让模块本身变得更加内聚(模块内的代码关系更加紧密),进而业务开发者可以更加专注于业务逻辑本身。从迭代能力上来看,我们可以通过切面的方式进行修改或者新增功能,这种能力不管是在问题诊断还是产品能力扩展中,都非常有用。

Spring AOP的切入点和切入行为如何定义?

  • Aspect(声明当前类包含AOP的切入点和切入行为),通常叫作方面,它是跨不同Java类层面的横切性逻辑。在实现形式上,既可以是XML文件中配置的普通类,也可以在类代码中用“@Aspect”注解去声明。在运行时,Spring框架会创建类似Advisor来指代它,其内部会包括切入的时机(Pointcut)和切入的动作(Advice)。

  • Join Point,它是Aspect可以切入的特定点,在Spring里面只有方法可以作为Join Point。

  • Advice,它定义了切面中能够采取的动作。一般有:前置通知、后置通知、环绕通知、异常通知,发生的顺序如下图:

Java面试题总结(下)插图12
  • Pointcut,它负责具体定义Aspect被应用在哪些Join Point,可以通过指定具体的类名和方法名来实现,或者也可以使用正则表达式来定义条件。
Java面试题总结(下)插图13
4者关系

22.3 为啥要使用单例而不是直接使用静态方法

https://www.cnblogs.com/seesea125/archive/2012/04/05/2433463.html

为了让开发更加模式化、面向对象化

静态方法:基于对象

单例模式的方法:面向对象

23、对比Java标准NIO类库,你知道Netty是如何实现更高性能的吗?

单独从性能角度,Netty在基础的NIO等类库之上进行了很多改进,例如:

  • 更加优雅的Reactor模式实现、灵活的线程模型、利用EventLoop等创新性的机制,可以非常高效地管理成百上千的Channel

  • 充分利用了Java的Zero-Copy机制,并且从多种角度,“斤斤计较”般的降低内存分配和回收的开销。例如,使用池化的Direct Buffer(一块在Java堆外分配的,可以在Java程序中访问的内存)等技术,在提高IO性能的同时,减少了对象的创建和销毁;利用反射等技术直接操纵SelectionKey使用数组而不是Java容器等。

  • 使用更多本地代码。例如,直接利用JNI调用Open SSL等方式,获得比Java内建SSL引擎更好的性能。

  • 通信协议、序列化等其他角度的优化

总的来说,Netty并没有Java核心类库那些强烈的通用性、跨平台等各种负担,针对性能等特定目标以及Linux等特定环境,采取了一些极致的优化手段。

23.1 Netty与Java自身的NIO框架相比有哪些不同呢?

对象的创建过程

假设有个名为Dog的类:

  1. 即使没有显式地使用static关键字,构造器实际上也是静态方法。因此,当首次创建类型为Dog的对象时(构造器可以看成静态方法),或者Dog类的静态方法/静态域首次被访问时,Java解释器必须查找类路径,以定位Dog.class文件。
  2. 然后载入Dog.class (这将创建一个Class对象),有关静态初始化的所有动作都会执行。因此,静态初始化只在Class对象首次加载的时候进行一次。
  3. 当用new Dog()创建对象的时候,首先将在堆上为Dog对象分配足够的存储空间。
  4. 这块存储空间会被清零,这就自动地将Dog对象中的所有基本类型数据都设置成了默认值(对数字来说就是0,对布尔型和字符型也相同),而引用则被设置成了null。
  5. 执行所有出现于字段定义处的初始化动作
  6. 执行构造器。这可能会牵涉到很多动作,尤其是涉及继承的时候。
赞(0) 打赏
未经允许不得转载:IDEA激活码 » Java面试题总结(下)

一个分享Java & Python知识的社区