0%

面试题-Java基础

1、Java 多态

点击看答案

多态存在的三个必要条件:一、要有继承关系 二、要重写方法 三、父类指向子类对象。

多态就是指允许不同类的对象(如:父类的多个子类),对同一消息做出不同响应(同一个函数调用在不同子类中的行为不同)。

多态的实现:动态绑定(dynamic binding),即在执行期间判断所引用的对象的实际类型,根据实际类型再调用相应方法。

Java 中多态的表现:接口的实现、继承父类进行方法重写 以及 同一个类中进行方法重载。

以上内容参考自: Java多态

Java集合

2、ArrayList 与 LinkedList的区别

点击看答案

ArrayList的特点:

  • 以数组实现,初始空间是10,节约空间
  • 有容量限制,当超出限制时,新增50%容量,即容量变为原来的1.5倍,如果还不够,则直接扩充为需求值,之后将原来数据拷贝到新的空间中,比较耗时
  • 按照数组下标访问元素——get(i)/set(i,e) 性能很高
  • 按照下标插入、删除元素,需要移动受影响的元素,性能就会变差(remove操作可以理解为删除index为0的节点,并将后面的元素移到0处)

LinkedList的特点:

  • 以双向链表实现,链表无容量限制,但是双向链表本身使用了更多空间
  • 按下标访问元素——get(i)/set(i,e),要遍历链表将指针移动到位(如果i>链表的一半,会从末尾开始移动)
  • 插入、删除元素时,只需要修改前后节点的指针即可。但如果是指定位置插入和删除,则还是需要遍历部分链表的指针才能移动到下标所指的位置。如果只是在链表两头的操作就能省掉指针的移动。

参考链接:ArrayListLinkedList)

3、HashMap

点击看答案

根据官方描述,HashMap 基于 Map 接口实现,允许null 键/值,非同步、不保证有序(比如插入顺序)、顺序可能会随时间变化。

两个重要的参数

容量(Capacity)就是bucket大小,负载因子(Load factor)就是bucket填满程度的最大比例。若对迭代性能要求高,则capacity不宜设置过大,同时load factor也不宜设置过小;当buckets的数目大于 capacity * load factor 时,就需要调整buckets 的大小为当前的2倍。

hashMap的put函数实现

  1. 对key的hashCode 做hash,然后计算index;
  2. 如果没有碰撞直接放到bucket里;
  3. 如果碰撞了,以链表的形式存在buckets 后;
  4. 如果碰撞导致链表过长,达到某个阈值后,则把链表转换成红黑树
  5. 如果节点已经存在,就替换 old value (保证key的唯一性);
  6. 如果bucket满了,就要resize;
  7. 注意,插入元素采用头插法,因为HashMap的发明者认为,后插入的Entry被查找的可能性更大

get函数的实现

  1. bucket里第一个节点,则直接命中;
  2. 如果有冲突,则通过 key.equals(k) 方式去查找对应的 entry。

若为树,则树中通过key.equals查找,时间复杂度为 O(logn);
若为链表,则链表中通过key.equals查找,时间复杂度为 O(n)。

hashmap细节

  • hashmap的初始长度是16,并且手动初始化或者每次自动扩展时,长度必须是2的幂

这里主要强调是2的幂,至于为什么是16,主要是为了让key到index的映射更加均匀。前面提到,index = Hash(key) ,如何实现一个尽量分布均匀地hash函数。有人说可以通过求余的方式: index = hashCode(key) % length ,不过求余的方式虽然简单,但是效率不高,Hashmap中采用了位运算方式,其公式为:

index = hashCode(key) & (length - 1)

以具体例子来说,假如某个key 的 hashCode(二进制) = 101110001110101110 1001,hashmap的默认长度length = 16 ,则 length - 1 为15,二进制数据为 1111,把两个二进制数据做位与操作得到 1001,即十进制的 9 ,所以index = 9。所以这里,index的结果完全取决于hashCode的最后4位(当然,java8中,会将这个hashCode的高16位不变,低16位和高16位做异或操作作为低16位的值,之后才与 length -1 做位与,这样避免只有低4位是有效位,从而进一步降低碰撞,因为参与的位数多了)

所谓为什么要是2的幂,也即如果不是2的幂会怎样?比如hashMap的长度是 10,还是以上面的例子:hashCode(二进制) = 101110001110101110 1001,length - 1 = 9 ,即 1001,则index 也还会是1001(因为hashCode的后4位也是1001),单独看这里没什么问题,但是从此我们可以推断:如果hashCode的后 4 位是 1001、1101、1111,1101等等,它们的结果都会是 1001,因为相当于只有第1位和第4位在起作用,这不符合index均匀分布的要求。那如果是2的幂呢?则length - 1后,所有的位数都是1,则每位都会起作用

Hashmap的容量是有限的,当容量达到一定的饱和度的时候,Key映射位置发生碰撞的概率会上升,这好理解,因为如果每个坑都差不多有entry在了,无论你index是多少,都会碰撞,所以元素越多,越容易发生碰撞。java中的条件是 : hashmap.size >= capacity * load factor 的时候,就需要resize,需要经历两个步骤,1、扩容,创建一个新的Entry数组,长度是以前的2倍,2、ReHash,遍历原来的 Entry数组,把所有的Entry 重新hash到新的数组,因为数组长度变化了,hash的规则也会改变,所以需要rehash。这里不需要重新计算hash,只需要判断原来的hash值新增的那位是0 还是1,如果是0的话,索引还是没变化,如果是1,则索引变成 “原索引 + oldCapacity”。

举个例子拉说,如果以前的capacity 是 8,则resize后变成16,以前的length - 1 为 111 现在则变成了 1111,多了一个有效位,所以只要判断 hashCode 的对应新增的那位的值是0还是1了,0的话,整个index还是不变,1的话,就在index的基础上加上老的容量 8 即可。

  • 前面提到,key和value都可能为null,如果key为null,则直接从哈希表的第一个位置table[0]对应的链表上查找。记住,key为null的键值对永远都放在以table[0]为头结点的链表中。

  • Hashmap的线程不安全如何体现?

  1. 如果多个线程同时使用put添加元素,如果发生碰撞,最终只有一个线程值被保存,因为另一个的会被覆盖。
  2. 由于resize操作存在,hashmap在多线程的情况下,可能会出现死循环,具体参考:小灰的解释

参考链接: 知乎链接hashmap介绍链接

4、HaShMap 链表元素到达8的时候转红黑树的若干问题

点击看答案

首先,得满足两个条件才会转红黑树:一个是链表长度到8,一个是数组长度到64

为什么到8才转成红黑树?首先根据统计节点数>=8概率是很小的(千分之一),并且到8的时候,会引起性能下降,且因为转红黑树消耗性能,所以到 8 才转。

会根据红黑树状态以及红黑树节点总数到6这个阈值来将红黑树退回链表,这主要是是因为 8 和 6 这两个数字相差2,不至于插入删除一个元素导致来回转换

为什么不一开始就采用红黑树?因为红黑树是有额外的空间开销的,并且红黑树涉及左旋右旋等操作(我自己臆测的,也没看到有好的说法)

以上问题参考自hashmap转红黑树的两个条件 HashMap的问题cnblogs的博客

5、HashTable

点击看答案

HashTable 类似于HashMap ,它同样基于hash表实现,每个元素也是key-value 对,也是通过单链表解决冲突,容量不足时,也会resize,二者区别是:

  1. HashTable 的key和Value 都不能为null,而HashMap允许。
  2. HashTable 默认大小是 11,扩容方式是 old*2 + 1,而HashMap 默认大小是16,要求数组大小是2的幂,扩容时,直接扩为2倍。
  3. 获取index的方式不一样,Hashtable 采用除余的方式,而HashMap采用 位与的方式,效率更高。
  4. HashTable 保证方法调用的线程安全,因为在每个方法前都有synchronize 关键字。而HashMap 没有,因此在线程安全条件下效率更高。

上文的参考链接

6、ConcurrentHashMap

点击看答案

想要避免HashMap 的线程安全问题有很多办法,比如采用 HashTable 或者 Collections.synchronizedMap ,但是这两者有共同的问题:性能,因为无论是读还是写操作,它们都会给整个集合加锁,导致同一时间的其他操作阻塞。如下图所示:

阻塞其他操作

此时,ConcurrentHashMap应运而生,理解 ConcurrentHashMap 关键要理解一个概念: Segment 。Segment 本身就相当于一个 HashMap 对象,Segment 包含一个HashEntry 数组,数组中每个 HashEntry 既是一个键值对,也是一个链表的头结点,如下图所示:

单一segment结构

这样的Segment 在ConcurrentHashMap 中有2 的N 次方个,共同保存在一个名为 segments 的数组中。因此,整个 ConcurrentHashMap 的结构如下:

ConcurrentHashMap结构

这个二级结构,和数据库的水平拆分有些相似。采取这样的结构就是锁分段技术,每个segment 就好比一个自治区,读写操作互不影响。所以,ConcurrentHashMap 操作会有以下几种可能性:

  • 不同Segment 可以并发写入。
  • 同一 Segment 可以同时读和写。
  • 同一个 Segment 并发写入时,只有一个线程可以执行,其他的线程阻塞。因为 Segment 的写入会加锁。

通过以上分析我们知道,ConcurrentHashMap 中每个 Segment 各自持有一把锁。在保证线程安全的情况下,降低了锁的粒度,让并发操作效率更高。

get 方法

  1. 为输入的key做 Hash 运算,得到hash值。
  2. 通过hash值,定位到对应的 Segment 对象。
  3. 再次通过 hash 值,定位到 Segment 中数组的具体位置。

put 方法

  1. 为输入的key 做Hash 运算,得到hash 值。
  2. 通过hash值,定位到 Segment 对象。
  3. 获取可重入锁。
  4. 再次通过 hash 值,定位到 Segment 当中的具体位置。
  5. 插入或者覆盖 HashEntry 对象。
  6. 释放锁。

size 方法

获取 ConcurrentHashMap 总元素数量,自然要把各个 Segment 的元素汇总起来,但是如果在统计过程中,已经统计过的 Segment 瞬间插入新的元素,这时候怎么办呢?其实,这个size调用过程的大体逻辑如下:

  1. 遍历所有 Segment。
  2. 把 Segment 的元素数量累加。
  3. 把 Segment 的修改次数累加起来。
  4. 判断所有Segment 的总修改次数是否大于上一次的修改次数,如果大于,说明统计过程中有修改,重新统计,同时尝试次数 +1;否则,说明没有修改,统计结束。
  5. 如果尝试次数超过阈值,则对每一个Segment 加锁,再重新统计。
  6. 此时,统计的结果肯定正确,统计结束,释放锁。

为了尽量不锁住所有的Segment ,首先乐观假设Size过程中不会有修改,当尝试一定次数后,才无奈转换为悲观锁。

以上文章参考小灰的分析

7、HashMap 、 HashTable 和 ConcurrentHashMap 的区别?

点击看答案

HashTable 是 HashMap 的线程安全实现,但是 HashTable在竞争激烈时效率低下,因为访问所有 HashTable 的线程都竞争同一把锁。ConcurrentHashMap 采用锁分段技术,将数据一段段存储,每段一把锁,当两个线程访问不同段数据时不受干扰,当然,contentValue和size等方法需要了解整体数据的情况下,还需要锁住整个表。

8、LinkedHashMap

点击看答案

LinkedHashMap 是Hash表和链表的实现,并且保存了记录的插入顺序。因为LinkedHashMap里面的Entry比HashMap多了两个字段:after和before,而以前的HashMap中的next 字段没有变化,从而额外构成一个双向链表,当然就可以在keySet()时按插入顺序输出,LinkedHashMap结构如下图所示:

LinkedHashMap原理

与HashMap的区别:1、保存了记录的插入顺序,遍历的时候,首先打印最先插入的记录。2、遍历的时候比HashMap慢,因为LinkedHashMap 遍历链表,而HashMap可以说是根据capacity 遍历链表。不过如果HashMap容量很大并且实际数据比较少的情况下,遍历起来可能比LinkedHashMap慢。3、HashMap的遍历速度和容量有关,而LinkedHashMap 遍历速度只和实际数据有关。

以上内容参考这个链接,从知乎这个问题得到了灵感,忽然想明白了这个结构。

9、WeakHashMap

点击看答案

WeakHashMap,从名字可以看出它是某种 Map。它的特殊之处在于 WeakHashMap 里的entry可能会被GC自动删除,即使程序员没有调用remove()或者clear()方法。

更直观的说,当使用 WeakHashMap 时,即使没有显示的添加或删除任何元素,也可能发生如下情况:

  • 调用两次size()方法返回不同的值;
  • 两次调用isEmpty()方法,第一次返回false,第二次返回true;
  • 两次调用containsKey()方法,第一次返回true,第二次返回false,尽管两次使用的是同一个key;
  • 两次调用get()方法,第一次返回一个value,第二次返回null,尽管两次使用的是同一个对象。

遇到这么奇葩的现象,你是不是觉得使用者一定会疯掉?其实不然,WeekHashMap 的这个特点特别适用于需要缓存的场景。在缓存场景下,由于内存是有限的,不能缓存所有对象;对象缓存命中可以提高系统效率,但缓存MISS也不会造成错误,因为可以通过计算重新得到。

以上内容参考 知乎的大神

10、TreeMap

点击看答案

HashMap不保证数据有序,LinkedHashMap保证数据可以保持插入顺序,而如果我们希望Map可以保持key的大小顺序的时候,我们就需要利用TreeMap了。从官方的描述来看:

A Red-Black tree based {@link NavigableMap} implementation.The map is sorted according to the {@linkplain Comparable natural ordering} of its keys, or by a {@link Comparator} provided at map creation time, depending on which constructor is used.

TreeMap 是一个红黑树结构,每个key-value都作为一个红黑树的节点。它根据 key 排序(Comparable自然排序),但假如 key 没有实现 Comparable 接口,还可以通过构造函数中传入的 Comparator 来自定义比较。并且它还间接实现了 SortedMap 接口,因此它是有序的集合。

使用红黑树的好处是能够使得树具有不错的平衡性,这样操作的速度就可以达到log(n)的水平了。

关于根据 key 排序这个表述,可能直接看代码更容易懂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V put(K key, V value) {

...

Comparator<? super K> cpr = comparator;
if (cpr != null) {

...

cmp = cpr.compare(key, t.key);

...
} else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;

...

}
}

省略了很多代码,如果有自定义的 comparator,则使用自定义的 comparator 比较;否则,将 Key 强转为 Comparable 类型,再做比较。当然,这个Key肯定不能为null,此外,官方也说明了,如果Key不是 Comparable 类型的,就会抛出 ClassCastException 异常。

以上文章参考java集合-TreeMaposchina链接 还有这个大牛

11、Java泛型

点击看答案

Java 泛型主要关注几点:

类型通配符

顾名思义就是匹配任意类型。如如下写法:List<?> list ;

带限通配符

上限通配符:使用extends 关键字指定这个类型必须继承某个类或者实现某个接口,也可以是该类(接口)本身。如: List<? extends Shape> ,表示集合中所有元素都是Shape 类型或者它的子类。

下限通配符:使用super 关键字指定这个类型必须是某个类的父类,或者某个接口的父接口,也可以是这个类本身。如:List<? super Circle> ,表示集合中所有元素都是Circle 类型或者是其父类。

类型擦除

Class c1=new ArrayList().getClass();
Class c2=new ArrayList().getClass();
System.out.println(c1==c2);

输出 true ,也就是说编译后的class文件中不会包含任何泛型信息,泛型信息不会进入到运行时阶段。

由于系统中并不会真正生成泛型类,所以instanceof运算符后不能使用泛型类

以上内容参考自Java泛型

12、Java抽象类和接口的区别

点击看答案

abstract class和interface是Java语言中对于抽象类定义进行支持的两种机制,正是由于这两种机制的存在,才赋予了Java强大的面向对象能力。

abstract class和interface之间在对于抽象类定义的支持方面具有很大的相似性,甚至可以相互替换,因此很多开发者在进行抽象类定义时对于 abstract class和interface的选择显得比较随意。

但是,对于它们的选择甚至反映出对于问题领域本质的理解、对于设计意图的理解是否正确、合理。

总结:

  • 设计理念上,接口反映的是”like-a”关系,抽象类反映的是”is-a”关系,即接口表示这个对象能做什么,抽象类表示的是这个对象是什么(想象一下,人可以吃东西,狗也能吃东西,接口反映的是吃东西这个动作,而抽象类能反映的,可能就是人这个物种)。
  • 抽象类与接口都不能直接实例化。
  • 抽象类被子类继承,接口被子类实现。
  • 接口中定义的变量只能是公共的静态常量(即 public static final),抽象类中是普通变量。
  • 抽象类中可以没有抽象方法,接口中可以没有方法,但是有方法一定要有抽象方法。
  • 接口可以被类多实现(类可以实现多个接口),抽象类只能被单继承。
  • 接口中没有this 指针,没有构造函数,不能拥有实例变量或实例方法。

关于接口,再多啰嗦几句:

  • 接口用于描述系统对外提供的服务,因此接口中的成员变量和方法都必须公开(public),确保所有使用者能访问。
  • 接口仅描述系统能做什么,但不指名如何做,因此所有方法都是抽象(abstract)方法。
  • 接口不涉及任何具体实例(this关键字)的相关细节,因此接口没有构造方法,没有实例变量,只有静态(static)变量。
  • 接口中的变量是所有实现类公有的,既然公有,肯定是不变的东西,所以变量是不可变(final)的。

通俗讲,你认为是要变化的东西,就放在你自己的实现中,不能放在接口中。接口对修改关闭,对扩展开放,是开闭原则的体现。

以上内容参考自:Java抽象类和接口的区别程序媛想事

13、Java transient关键字

点击看答案

Java序列化时,transient关键字用于属性前时,该属性就不会被序列化。它的使用可以总结为下面几点:

  • 变量被 transient 修饰时,变量将不会是对象持久化的一部分。
  • transient 只能修饰变量而不能修饰方法和类,并且也不能修饰本地变量。
  • 静态变量不管是否被 transient 修饰,均不能被序列化。

附:父类实现了Serializable,子类没有,
父类有int a = 1、int b = 2、int c = 3
子类有int d = 4、int e = 5
序列化子类的时候,d和e会不会被序列化?(答案:会)

反过来父类未实现Serializable,子类实现了,序列化子类实例的时候,父类的属性是直接被跳过不保存,还是能保存但不能还原?(答案:值不保存)

以上内容参考自:Java transient关键字

14、Java finally与return执行顺序

点击看答案

首先探讨下,try-catch-finally 块中的语句是否一定被执行?答案是否定的,原因有2个:

  • 如果try 语句没有被执行(比如在try 之前就return 了),finally就不会执行。
  • 如果try 块中有 System.exit(0)这样的终止Java 虚拟机的语句的话,finally就不会执行。这可以理解,连JVM 都停止了,啥都没有了。

关于finally 与return 的执行顺序,过程比较复杂,可以分为如下情况:

  • 正常情况下,finally 语句在return 语句执行之后,return 返回之前执行的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FinallyTest1 {

public static void main(String[] args) {
System.out.println(test11());
}

public static String test11() {
try {
System.out.println("try block");
return test12();
} finally {
System.out.println("finally block");
}
}

public static String test12() {
System.out.println("return statement");
return "after return";
}

}

会输出:

try block
return statement
finally block
after return

可以看出,try 中的return语句先执行了,但是值没有立即返回,等finally执行结束后再返回值。

  • 如果finnaly 块中有return 语句会覆盖 try 中的 return 返回。
  • 如果finally语句中没有return语句覆盖返回值,那么原来的返回值可能因为finally里的修改而改变也可能不变(int 类型和 Map 类型)。
  • try 块里的return 语句在异常情况下不会执行,怎么返回看情况。

以上内容参考自:Java finally与return执行顺序

15、两个对象的 hashcode 相同,是否对象相同?equal() 相同呢?

点击看答案
  1. hashCode是所有java对象的固有方法,默认返回的是该对象在jvm的堆上的内存地址,不同对象的内存地址肯定不同,所以这个hashCode也就肯定不同了。如果重载了的话,由于采用的算法的问题,有可能导致两个不同对象的hashCode相同。
  2. hashCode和equals两个方法是有语义关联的,它们需要满足:
    A.equals(B)==true –> A.hashCode()==B.hashCode(),但是反之不能说hashcode相等就equals
    因此重载其中一个方法时也需要将另一个也重载。
  3. 此外,请注意:hashCode的重载实现最好依赖于对象中的final属性,从而在对象初始化构造后就不再变化。一方面是jvm便于代码优化,可以缓存这个hashCode;另一方面,在使用hashMap或hashSet的场景中,如果使用的key的hashCode会变化,将会导致bug,比如放进去时key.hashCode()=1,等到要取出来时key.hashCode()=2,就取不出来数据了。
    综上所述,hashCode相同或者equals相同并不能说明对象相同。

16、延伸-Java 中 hashcode 的作用

点击看答案

官方文档的定义就是:

hashcode 方法返回对象的哈希码值,支持该方法主要是为了支持基于哈希机制的Java 集合类,如HashMap、HashSet、HashTable 等。

hashcode的常规约定是:

Java程序运行期间,同一个对象上多次调用 hashcode ,必须一致地返回相同的整数,而从某一应用程序的一次执行到同一程序的另一次执行,该整数无须保持一致。如果两个对象相等,那么两个对象中的每个对象上调用 hashcode 方法都必须生成相同的整数结果。

以上内容参考自:OUYM

17、延伸-为什么重写了equals 方法,要求必须重写hashcode 方法?

点击看答案

根据前面的内容,总结就是:为了满足常规约定-如果两个equals 满足,就一定要求返回相同的 hashcode。举个例子,如果重写了 equals 方法,对象中 num 和data 参与了equals 比较,那么 num 和data 也要参与生成hashcode,这是为了遵守上述约定。

18、生成hashcode 注意的事项

点击看答案
  • 返回的hash值是int型,防止溢出
  • 不同对象返回的hash值尽量不同(为了hashmap 等集合减少碰撞)
  • 无论何时,对同一个对象调用hashcode()都应该产生同样的值

最后一点是很重要的,也是容易出隐形bug的地方,如果将一个对象put() 到HashMap 时产生了一个 hashcode 值,而 get() 取出时却产生了另外一个hashcode,那么就无法获取该对象了。所以,如果hashcode() 方法依赖于对象中易变的数据,那就要当心了。

以上内容参考自:OUYM

19、下面代码输出的结果?

点击看答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
//题目1
String str1 = "hello";
String str2 = "he" + new String("llo");
System.err.println(str1 == str2);

//答案是false,因为str2中的llo是新申请的内存块,而==判断的是对象的地址而非值,所以不一样。

//题目2
public static void main(String args[]) {

Thread t = new Thread() {

public void run() {
pong();
}
};

t.run();
System.out.print("ping");

}

static void pong() {
System.out.print("pong");
}
}

//输出 pongping ,这里调用的是Thread 的run 方法,与普通方法是一样的,失去线程的特性了。因此我们要启动新县城执行的时候,需要使用Thread 的 start() 方法。

//题目3,如下代码是否能正常运行?
public class NULL {
public static void test(){
System.out.println("haha");
}
public static void main(String[] args) {
((NULL)null). test();
}
}

//能正常运行,输出 haha 。首先,null 值可以强转成任何 Java 类型,所以 (NULL)null 、(String)null 等都是合法的。其次,test 方法是static 方法,只和类名绑定,不借助对象进行访问。综上,能正常输出。反之,如果 test 非static ,就只能使用对象访问,这时候使用null对象肯定会报空指针。

//题目4,如何输出
class People {
String name;

public People() {
System.out.print(1);
}

public People(String name) {
System.out.print(2);
this.name = name;
}
}

class Child extends People {
People father;

public Child(String name) {
System.out.print(3);
this.name = name;
father = new People(name + ":F");
}

public Child() {
System.out.print(4);
}

}

//会输出132,在Java 中,子类的构造过程中必须调用其父类的构造函数,因为有继承关系存在,子类要把父类的内容继承下来。

//题目5,如何输出
public class HelloA {

public HelloA() {
System.out.println("HelloA");
}

{
System.out.println("I'm A class");
}

static { System.out.println("static A"); }
}

class HelloB extends HelloA {
public HelloB() {
System.out.println("HelloB");
}

{
System.out.println("I'm B class");
}

static {
System.out.println("static B");
}

public static void main(String[] args) {

System.out.println("-------main start-------");
new HelloB();
new HelloB();
System.out.println("-------main end-------");
}
}

//会输出如下结果:
static A
static B
-------main start-------
I'm A class
HelloA
I'm B class
HelloB
I'm A class
HelloA
I'm B class
HelloB
-------main end-------

这个题目很好,考察静态语句块、构造语句块(就只有大括号的那块) 以及 构造函数执行顺序。

对象初始化顺序:1. 类加载之后,从父类到子类执行被static 修饰的语句; 2.static 执行完毕后,再执行main 方法; 3.如果有语句new自身对象,将从父类到子类执行构造语句块、构造器。

20、延伸-类的初始化步骤

点击看答案

没有父类的情况:

  1. 类的静态属性
  2. 类的静态代码块
  3. 类的非静态属性
  4. 类的非静态代码块
  5. 构造方法

有父类的情况:

  1. 父类的静态属性
  2. 父类的静态代码块
  3. 子类的静态属性
  4. 子类的静态代码块
  5. 父类的非静态属性
  6. 父类的非静态代码块
  7. 父类构造方法
  8. 子类非静态属性
  9. 子类非静态代码块
  10. 子类构造方法

以题目深化理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Singleton {
private static Singleton singleton = new Singleton();
public static int counter1;
public static int counter2 = 0;

private Singleton(){
counter1 ++;
counter2 ++;
}

public static Singleton getSingleton(){
return singleton;
}
}

public class TestSingleton {
public static void main(String args[]){
Singleton singleton = Singleton.getSingleton();
System.out.println("counter1 = " + singleton.counter1);
System.out.println("counter2 = " + singleton.counter2);
}
}

上述代码将输出:

counter1 = 1
counter2 = 0

根据类初始化步骤,由于 Singleton 并没有被加载过,所以首先执行类加载步骤,在“准备”阶段,首先给静态变量赋初默认值:
singleton = null
counter1 = 0
counter2 = 0

加载和连接完毕,再进行初始化工作,依照代码写的顺序依次执行,首先执行 singleton = new Singleton();这样就会执行构造方法的内部逻辑,即此时 counter1 = 1; counter2 = 1;

接下来,由于counter1 只进行了定义,并没有初始化,所以counter1的值仍然为1 ;接下来,counter2 进行了定义并且赋值 0 ,则初始化阶段后,counter2 的值为0;

初始化完毕,要调用Singleton.getSingleton() ,由于singleton 的值已经初始化过,此时直接返回即可。因此输出 counter1 = 1,counter2 = 0。

反之,如果将静态变量初始化的顺序改变下:

1
2
3
public static int counter1;
public static int counter2 = 0;
private static Singleton singleton = new Singleton();
则会输出 counter1 = 1,counter2 = 1 了,按照上述推理应该能够理解。

以上内容参考自:码个蛋,同时可以参考以前写的虚拟机的相关文章

21、constructor 是否一定要与类名同名,方法名是否一定不能与类名同名?

点击看答案

constructor 是一定要与类名同名的,但普通的类方法是可以和类名同名的,它与构造方法唯一的区别就是构造方法没有返回值。

22、数据溢出与非法数据问题

点击看答案

存在使i + 1 < i的数吗?答案是肯定的,比如i是int 类型,那么当i 是最大的整数时,i+1就溢出了,就可能< i

是否存在 i>j || i<= j 不成立?答案是肯定的,比如:Double.NaN 或者 Float.NaN

以上内容参考自程序媛想事

23、Java 的参数传递

点击看答案

在讨论之前,首先看下如下代码的输出情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Example {

String str = new String("good");

char[] ch = { 'a', 'b', 'c' };

public static void main(String args[]) {
Example ex = new Example();
ex.change(ex.str, ex.ch);
System.out.print(ex.str + " and ");
System.out.print(ex.ch);
}

public void change(String paramA, char paramB[]) {
paramA = "test ok";
paramB[0] = 'g';
}
}

上述代码输出good and gbc 。在Java 中没有引用传递,只有值传递,这个值指的是实参的地址的拷贝,得到这个值(地址拷贝)后,你可以通过它修改这个地址的内容,因为此时这个内容的地址和原地址是同一个地址,但是你不能改变这个地址本身使其重新引用到其他对象。以上的意思说明仅仅只是值传递。具体过程如果使用图示的话,如下所示:

str的传递:

字符串传递

在change方法中重新为paramsA 赋值:

字符串传递

ch的传递:

数组传递

在change方法中更改paramsB 的元素值:

数组更改值

以上内容参考自:程序媛想事RavenXRZ

24、静态属性和静态方法是否可以被继承?是否可以被重写?以及原因?

查看以前写的这篇读书笔记即可

25、String、StringBuilder、StringBuffer、CharSequence 区别

点击看答案

CharSequence 是一个接口,String、StringBuilder、StringBuffer 都实现了这个接口,它们三个的本质都是通过字符数组实现的。

StringBuilder、StringBuffer 的char 数组开始的存储空间是16,如果append() 过程中超过这个容量,将会申请新的空间,并把老的数组一起复制过去。

StringBuffer 的每个处理方法都加上了 synchronized 关键字,因此可以说它是线程安全的。

26、Java 中String 为毛要设计成不可变?

点击看答案

为什么说String 是不可变的

首先我们看源码:

1
2
3
public final class String implements Serializable, Comparable<String>, CharSequence {
private final char[] value;
}

类使用final 修饰,说明不可继承;存放内容的 value 是个char[]数组,也是final修饰,创建以后就不可改变。说明一下,这个 value 是stack 上的一个引用,数据本身还是在heap堆上。final 修饰value ,只能是说stack 指向的引用地址是不可变的,但是堆里面的数据本身还是可变的!举例理解下:

1
2
3
4
5
6
final int[] value={1,2,3}
int[] another={4,5,6};
value = another;//编译器报错,final不可变

final int[] value={1,2,3};
value[2]=100;//这时候数组里已经是{1,2,100}

通过以上代码相信就能理解上面描述的意思了。也许有人还认为String是可以变的,并且举例如下:

1
2
String a = "abcd";
a = "abcdef";

这其实不是String本身变化,只是变量a指向heap堆的指针发生了变化,而String本身并没有发生变化,示意图如下:

String重新赋值示意图

为什么要设计成不可变

首先,先得清楚 final 这个关键字。 final的出现就是为了为了不想改变,而不想改变的理由有两点:设计(安全)或者效率。

  • 字符串常量池的需要。String 带有字符串常量池的属性,如果两个字符串one和two都指向 “something” 赋值,它们其实都指向同一个内存地址。这样在大量使用字符串的情况下,可以节省内存空间,提高效率。之所以能实现这个特性,String 的不可变是必要的(如果可变,那么一个改了,所有引用常量池这个string值都会改变)。
  • 允许String对象缓存HashCode。String 对象的哈希码被频繁使用,比如在HashMap 中。
  • 其次,为了安全。多线程安全:多个线程同时读一个资源,不会引发竞态条件,但是对资源做写操作就会有危险,这样保证String使用线程安全。url、反射所需要的参数等都是String类型,如果允许改变,会引起安全隐患(比如非法访问:如果String可变,那么可以在安全检测后,修改String值,导致非法访问)。

一定是不可变的吗?

由以上内容可知,String 是通过字符数组实现的,这个字符数组被final 修饰,因此不能重新指向其他内存区域,但是,我们可以针对这块内存区域改变值,即改变这个数组里面的内容,比如将 value[0] 的值由 ‘a’ 改成 ‘b’(当然这个过程要通过反射去实现)。

可能令你疑惑的操作方式

我们平日开发通常情况下少量的字符串拼接其实没太必要担心,例如:

String str = “aa”+”bb”+”cc”;

像这种没有变量的字符串,编译阶段就直接合成”aabbcc”了,然后看字符串常量池(下面会说到常量池)里有没有,有也直接引用,没有就在常量池中生成,返回引用。

如果String a = “123”;这种写法是会将 “123” 放入常量池的,但是如果使用 String b = new String(“123”); 则会在堆上分配空间存放。

但是如果:

1
2
3
String str1 = "aaa";
String str2 = "bbb";
String str = str1+str2;

则在编译的时候会优化成: StringBuilder sb = StringBuilder(String.valueOf(str1))).append(str2)

如果:

1
2
3
4
StringBuffer sb = new StringBuffer();
String s = null;
sb.append(s);
System.out.println(sb.length());

则会输出4,因为如果是null的话,则会拼接 “null”。

以上内容参考自:qingmengwuheng1 以及 岚之山

27、成员内部类、静态内部类、局部内部类和匿名内部类的理解

点击看答案

成员内部类

成员内部类定义在另一个类的内部,它是依赖外部类而存在的,也就是说如果要创建内部类的对象,前提是必须存在一个外部类的对象,以下是两种内部类使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TestClass{
public void haha(){
//典型用法:在当前外部内中直接使用内部类
InnerClass innerClass = new InnerClass();

//非典型用法:使用某个类的内部类
OutBean outBean = new OutBean();
OutBean.InnerBean = outBean.new InnerBean();
}

class InnerClass{
}

class OutBean{
class InnerBean{
}
}
}

注意代码中典型用法和非典型用法成员内部类对象的创建,说明了内部类对象的创建是依赖于外部类对象的,尤其是: outBean.new InnerBean() 这种写法。

成员内部类可以用private、protected、public 及包访问权限修饰,如果成员内部类被private修饰,则只能在外部内的内部访问;如果使用public修饰,则任何地方都能访问;如果使用protected修饰,则只能在同一个包下,或者继承外部类的情况下访问;如果是默认访问权限,则只能在同一个包下访问。

成员内部类可以无条件访问外部类所有的成员属性和方法(包括private的和static的),不过如果内部类和外部类有相同名称的变量或者方法时,优先访问内部类自己的,如果要访问外部类的可以如下写法:

外部类.this.成员变量(方法)

局部内部类

局部内部类是定义在一个方法或者一个作用域里面的类,它和成员内部类的区别在于它的访问尽心阿玉方法内或者作用域内。由于类似于局部变量,所以并不能有 public、protected、private 或者 static 修饰符的。示例如如下代码:

1
2
3
4
5
6
7
public People getWoman(){
class Woman extends People{//局部内部类
int age = 0;
}

return new Woman();
}

匿名内部类

匿名内部类我们使用很多,比如在实现点击监听的时候:

1
2
3
4
5
6
btnOk.setOnclickListener(new OnclickListener(){
@Override
public void onClick(){

}
});

匿名内部类是为一种没有构造器的类,大部分匿名内部类用于接口回调,一般来说,匿名内部类用于继承其他类或者接口,并不需要增加额外地方法,只是对继承方法的实现或者重写。

静态内部类

静态内部类定义在一个类里面,并且被static 修饰,并不需要依赖于外部类。这点和类的静态成员属性有点类似,并且它不能使用外部类的非static 成员变量或者方法。

深入理解内部类

1、为什么成员内部类可以无条件访问外部类成员?

因为编译器会默认为内部类构造器中添加一个参数,这个参数是外部类对象的一个引用,所以它能直接访问外部内的成员。这也从侧面说明成员内部类为什么要依赖于外部类的对象。

2、为什么局部内部类和匿名内部类只能访问局部final 变量?

我们首先看如下代码:

1
2
3
4
5
6
7
8
9
10
public void test(final int b){
final int a = 10;

new Thread(){
public void run(){
System.out.println(a);
System.out.println(b);
}
}.start();
}

如果把变量a或者b任意一个final 去掉,代码就编译不过。至于为什么,我们首先考虑一个问题:当test 方法执行完成后,变量a的生命周期结束了,而Thread对象的生命周期可能还没结束,那么在Thread的run方法中继续访问a就实现不了了,但是又要实现这样的效果,怎么办?Java中采用了 复制 手段来解决,也就是将a复制到Thread对象中。

也就是说,如果局部变量的值在编译期间就能确定,则直接在匿名内部类中创建一个拷贝;如果局部变量的值无法再编译期间确定,则通过构造器传参的方式来对拷贝进行初始化赋值

内部类的场景和好处

  • 每个内部类都能独立继承一个接口实现,所以无论外部类是否已经继承了某个实现,对内部类都没有影响。内部类使得多继承的解决方案变得完整
  • 方便将存在一定逻辑关系的类组织在一起,又可以对外隐藏
  • 方便编写事件驱动程序(如实现点击监听)

以上内容参考自:Matrix海 子

28、多维数组在内存上是怎么存储的

点击看答案

Java 中的多维数组就是通过一维数组来实现的,只不过这个一维数组中的元素还是多维数组,比如如下声明:

1
int[][][] array = new int[2][3][4];  

它实际上大致等同于如下代码:

1
2
3
4
5
6
7
int[][][] a = new int[2][][];  
for (int i = 0; i < a.length; i++) {
a[i] = new int[3][];
for (int j = 0; j < a[i].length; j++) {
a[i][j] = new int[4];
}
}

如果要自己用一维数组去实现二维(或者更多维)的数组,可以使用类似规律: k = j*(j-1)/2 + i -1 来计算出在一维数组中的下标 k 值。

以上代码参考自iteye中的博客

29、泛型

点击看答案

一个例子

一个很经典的例子,可以告诉我们为什么需要泛型:

1
2
3
4
5
6
7
8
List list = new ArrayList();
list.add("aaa");
list.add(100);

for(int i =0;i< list.size();i++) {
String item = (String)list.get(i);
Log.d("Test", "item = " + item);
}

上述例子for语句上面的操作都是合法的,但是在for语句里面,执行到i==1的时候,会报错(运行的时候报错):

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

为了能够在编译期间就能发现这种问题,就可以使用泛型,如下所示:

List list = new ArrayList();

list.add(100);//编译阶段就会报错

所以,在被问到 ”我初始化一个List,但是没有指定类型,那么,我是不是可以添加任何类型的值?“,那么,答案是肯定的,可以添加任何类型的值,只要你能正确取出(在取的时候,知道每个位置存储的元素的类型),我自己写的例子如下:

1
2
3
4
5
6
7
8
9
10
11
List list = new ArrayList();
list.add("aaa");
list.add(100);

for(int i =0;i< list.size();i++) {
if(i == 0) {
System.out.println((String)list.get(i));
} else if(i == 1) {
System.out.println((int)list.get(i));
}
}

特性

泛型只在编译阶段有效,运行阶段会将特定类型擦除:

1
2
3
4
List<String> strList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();

//那么strlist.getClass 与 integerList.getClass 的结果是一样的(equals比较)

泛型的使用

泛型有三种使用方式,泛型类、泛型接口、泛型方法。一个普通的泛型类如下:

1
2
3
4
5
6
7
8
9
10
public class Test<T>{
private T key;
public Test(T key) {
this.key = key;
}

public T getKey(){
return key;
}
}

然后,我们日常的使用就是类似如下代码:

1
2
3
Test<String> testString = new Test<String>("123");
//或者
Test<Integer> testInt = new Test<Integer>(456);

我们定义了泛型类,我们就一定要传入泛型类型的实参么?想想我们最开始的那个例子,很显然并不是这样的。在使用反省的时候如果传入泛型实参,则会根据传入的泛型实参做相应限制,此时泛型才会起到应有的作用;如果不传入泛型类型的实参,则可以为任何类型。看如下例子都是合法的:

1
2
3
Test test1 = new Test("111");
Test test2 = new Test(222);
Test test3 = new Test(false);

这里要注意一点,泛型类型的参数只能是类类型,不能是简单类型

泛型接口

泛型的接口示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface Test<T> {
public T next();
}

//当然,实现的时候,可以不传入泛型实参
class TestImp<T> implements Test<T>{
public T next(){
return null;
}
}

//当然,你也可以传入实参
class TestImpl implements Test<String> {
public T next(){
return "";
}

泛型通配符

通配符一般使用 ? 代替具体的类型实参,注意这个 ? 是类型实参,不是类型形参。再直白一点,此处的 ? 和 Number、String、Integer 是一样的,都是一种实际类型,具体代码表示如下:

1
2
3
public void showKeyValue(Test<?> test) {
System.out.println("key is :" + test.getKey());
}

这样可以解决当具体类型不知道的时候,这个通配符就是 ? ;当操作类型时,不需要使用类型的具体功能时,那么可以用 ? 来表示未知类型。

泛型方法

泛型方法的定义相对复杂,泛型方法是指在调用方法的时候指明泛型的具体类型,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public <T> T func(Class<T> tClass) throws InstantiationException {
T instance = tClass.newInstance();
return instance;
}

//或者 返回类型为 void

public <T> void func(T t) {
System.out.println(t.toString());
}

//当然,方法的泛型可以和类的泛型不一样
class Test<T> {
//E 可以是任意类型,可以与类型T相同,也可以不同
public <E> void func(E t){

}
}

静态方法与泛型

静态方法有一种情况需要注意,那就是在类的静态方法中使用泛型:静态方法无法访问类上定义的泛型;如果静态方法操作的引用数据类型不确定的时候,必须要将泛型定义在方法上

1
2
3
4
5
6
7
public class StaticTest<T>{
//如果在类中定义使用泛型的静态方法,需要添加额外的泛型声明(将这个方法定义成泛型方法),即使静态方法要使用的泛型已经在类中的泛型生命果也不行

public static <T> void show(T t){

}
}

泛型上下边界

List<? extends Animal>,?表示的类型可以是Animal类型本身和Animal的子类。可以把Animal称为这个通配符(?)的上限(upper bound)。

<? super Type> 通配符 ? 表示它必须是Type本身,或是Type的父类

以上内容参考自 博客园上的博客简书上的博客

30、谈谈Java集合中那些线程安全的集合 & 实现原理

点击看答案

暂无

31、注解

点击看答案

在JAVASE中的注解有3个它们分别是:@Overried 重写,@Deprecated 不建议使用,@SupperssWarning 去除警告信息 。

注解格式如下:

1
2
3
public @interface 注解名称{
属性列表;
}

如果说注释是写给人看的,那么注解就是写给程序看的。它更像一个标签,贴在一个类、一个方法或者字段上。它的目的是为当前读取该注解的程序提供判断依据及少量附加信息。比如程序只要读到加了@Test的方法,就知道该方法是待测试方法。

@interface和interface从名字上看非常相似,我猜注解的本质是一个接口(当然,这是瞎猜)。为了验证这个猜测,我们做个实验。先按上面的格式写一个注解(暂时不附加属性):

注解类

之后,反编译:

注解类

我们发现,@interface变成了interface,而且自动继承了Annotation !

为了探究原理,首先我们看一下Overried 注解的底层实现:

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

其中Overried注解的上面有两行代码其中一行的修饰符为@Target和@Retention这两个注解是元注解,元注解用来修饰注解,我们来看看这两个注解的底层实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Target {
/**
* Returns an array of the kinds of elements an annotation interface
* can be applied to.
* @return an array of the kinds of elements an annotation interface
* can be applied to
*/
ElementType[] value();
}



@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {
/**
* Returns the retention policy.
* @return the retention policy
*/
RetentionPolicy value();
}

Target注解的类里面有一个属性名叫value,他是个数组类型,我们再看看Target注解的用法:

1
2
3
4
@Target({ElementType.METHOD,ElementType.TYPE,ElementType.FIELD})
public @interface huhu {

}

Target表示注解修饰的地方,常用的有3个分别是加载在类上,方法上或者属性上。分别为:ElementType.METHOD,ElementType.TYPE,ElementType.FIELD

Retention表示什么时候读取到这个注解,RetentionPolicy.SOURCE代表源文件读取注解,RetentionPolicy.Class代表编译后读取注解,RetentionPolicy.RUNTIME 代表运行时读取到注解。

以下以一个完整例子展示如何使用注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class JavaMain {
public static void main(String[] args) throws Exception {
// 获取类上的注解
Class<Demo> clazz = Demo.class;
Huhu annotationOnClass = clazz.getAnnotation(Huhu.class);
System.out.println(annotationOnClass.getValue());

// 获取成员变量上的注解
Field name = clazz.getField("name");
Huhu annotationOnField = name.getAnnotation(Huhu.class);
System.out.println(annotationOnField.getValue());

// 获取hello方法上的注解
Method hello = clazz.getMethod("hello", (Class<?>[]) null);
Huhu annotationOnMethod = hello.getAnnotation(Huhu.class);
System.out.println(annotationOnMethod.getValue());

// 获取defaultMethod方法上的注解
Method defaultMethod = clazz.getMethod("defaultMethod", (Class<?>[]) null);
Huhu annotationOnDefaultMethod = defaultMethod.getAnnotation(Huhu.class);
System.out.println(annotationOnDefaultMethod.getValue());
}


@Target({ElementType.METHOD,ElementType.FIELD,ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Huhu {
String getValue() default "no description";

}

@Huhu(getValue = "onClass")
public static class Demo{
@Huhu(getValue = "onFild")
public String name;

@Huhu(getValue = "onMethod")
public void hello(){}

@Huhu()//故意不指定,则会拿默认值
public void defaultMethod(){}
}
}

根据反射,可以获得各个位置的值,Class、Method、Field对象都有个getAnnotation()方法,可以获取各自位置上的注解信息。

以上内容参考自知乎、以及 简书上的博客

32、序列化

点击看答案

关于构造函数

反序列化时,并没有通过 Person 类的构造函数,不管是有参的还是无参的,它是根据序列化的数据创建的

关于序列化的部分

  • 被static和transient修饰成员无法按默认方式序列化。

  • 父类也需要实现Serializable接口才能把父类属性序列化。

  • 父类没有实现 Serializable 接口时,虚拟机是不会序列化父对象的,而一个 Java 对象的构造必须先有父对象,才有子对象,反序列化也不例外。所以反序列化时,为了构造父对象,只能调用父类的无参构造函数作为默认的父对象

关于同一对象多次序列化

如果执行2次 ObjectOutputStream..writeObject(obj); 则第二次写入对象时文件只增加了 5 字节,并且2次反序列化后两个对象是相等的,这是为什么呢?因为Java 序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用。

自定义序列化过程

虚拟机会试图调用对象类里的 writeObject ()和 readObject() 方法,进行用户自定义的序列化和反序列化,如果没有这样的方法,则默认调用是 ObjectOutputStream 的 defaultWriteObject() 方法以及 ObjectInputStream() 的 defaultReadObject 方法。由于可以自定义,使用writeObject()和 readObject() 方法,可以序列化static和transient修饰的成员

Externalizable完全定制序列化

以上内容参考自敲破苍穹的博客

谢谢你的鼓励