Java面试需要准备的东西很多,包括但不限于Java基础、常用API类、面向对象思想、经典jdk源码分析,集合类、并发与多线程、IO/NIO/AIO、反射与代理、JVM、TCP/IP、数据库、redis、数据结构与算法、设计模式、Git、Maven、分布式/微服务/云服务、前后端分离、vue全家桶、spring全家桶等等。要想每一个知识点都掌握显然难度不小,但在平常学习过程中对一些面试经常会遇到的问题做点总结,深扒一下底层原理,就可以在面试中脱颖而出。本篇内容主要是和面向对象有关的知识点,相关问题在实际面试中出现的频率非常高,值得记录下来跟道友一起分享。
1 Java的自动装箱拆箱过程
1.1 Java中基本数据类型与包装类型
Java基本数据类型有八种,主要分为四类:
字符类型: char
布尔类型: boolean
整数类型: byte、short、int、long
浮点类型: float、double
在Java语言中new一个对象是存储在堆里的,我们通过栈中的引用来使用这些对象;所以,对象本身来说是比较消耗资源的。而基本数据类型的变量不需要用new创建,他们不会在堆上创建,而是直接在栈内存中存储,因此会更加高校。
Java语言是一个面向对象的语言,但是Java中的基本数据类型却不是面向对象的,这在实际使用时存在许多不便,为了解决弥补这个不足,在设计每个基本数据类型时设计了一个对应的类进行代表,这样八个和基本数据类型对应的类统称为包装类(Wrapper Class)。包装类均位于java.lang包,包装类和基本数据类型的对应关系如下表所示。
基本数据类型 | 包装类 |
---|---|
byte | Byte |
boolean | Boolean |
short | Short |
char | Character |
int | Integer |
long | Long |
float | Float |
double | Double |
Java语言在编程时很多地方都需要使用对象而不是基本数据类型。比如在集合类中我们无法将int、double等基本数据类放进去,因为集合容器要求元素是Object类型的。为了让基本数据类型也具有对象的特征,就出现了包装类型,它相当于将基本数据类型包装起来,使得它具有了对象的性质,并为其添加了属性和方法,丰富类基本类型的操作。
1.2 Java中的自动装箱与自动拆箱的概念及其实现原理
有时候需要在基本数据类型和包装类之间进行转换,将基本数据类型转换成包装类就是装箱,反之就是拆箱。在Java SE5中为了减少开发人员的工作,Java提供了自动拆箱与自动装箱功能,通过代码比较一下实现自动前后的区别。
1 | Integer i = new Integer(10);//装箱 |
实际上,自动装箱都是通过valueOf()方法来实现的,自动拆箱都是通过包装类对象的xxxValue()来实现的。
1.3 自动拆装箱的应用场景与存在的问题
将基本数据类型放入集合类时、包装类型和基本数据类型的大小比较、包装类型的运算、三目运算符的使用、函数参数与返回值,这几种情况下都会发生自动拆装箱。
Java SE的自动拆装箱还提供了一个和缓存有关的功能,我们先来看一下代码,猜测一下输出结果:
1 |
|
我们普遍认为上面的两个判断的结果都是false。虽然比较的值是相等的,但是由于比较的是对象,而对象的引用不一样,所以会认为两个if判断都是false的。在Java中,==比较的是对象应用,而equals比较的是值。所以,在这个例子中,不同的对象有不同的引用,所以在进行比较的时候都将返回false。奇怪的是,这里两个类似的if条件判断返回不同的布尔值。
1 | integer1 == integer2 |
原因就和Integer中的缓存机制有关。在Java 5中,在Integer的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。我们只需要知道,当需要进行自动装箱时,如果数字在-128至127之间时,会直接使用缓存中的对象,而不是重新创建一个对象。
自动拆装箱是一个很好的功能,大大节省了开发人员的精力,不再需要关心到底什么时候需要拆装箱。但是,他也会引入一些问题。包装对象的数值比较,不能简单的使用==,虽然-128到127之间的数字可以,但是这个范围之外还是需要使用equals比较。前面提到,有些场景会进行自动拆装箱,同时也说过,由于自动拆箱,如果包装类对象为null,那么自动拆箱时就有可能抛出空指针异常。
2 为什么有时候需要重写hashcode和equal方法,作用是什么?
2.1 equals方法
equals方法在Object类中默认的实现方式是return this == obj 。只有当this和obj引用同一个对象,才会返回true。而我们往往需要用equals来判断2个对象是否等价,而非验证他们的唯一性。这样我们在实现自己的类时,就要重写equals方法。
1 | //重写equals方法 |
2.2 hashCode方法
hashCode()方法返回对象的散列码,返回值是int类型的散列码。对象的散列码是为了更好的支持基于哈希机制的Java集合类,例如Hashtable,HashMap,HashSet等。上面代码块中Test类对象有2个字段,num和data,这两个字段代表了对象的状态,他们也用在equals方法中作为评判的依据。那么在hashCode方法中这两个字段也要参与hash值的运算,作为hash运算的中间参数。这点很关键,这是为了遵守两个对象equals,那么hashCode一定相同的规则,因此重写了euqals()方法的对象必须同时重写hashCode()方法。
重写hashCode方法时,一致性的约定是:
- 在某个运行期间,只要对象的变化不会影响equals方法的决策结果,那么在这个期间,无论调用多少次hashCode,都必须返回一个散列码。
- 如果2个对象通过equals调用后返回是true,那么这2个对象的hashCode方法也必须返回同样的int型散列码。
- 如果2个对象通过euqals调用后返回是fasle,它们的hashCode返回的值允许相同(然而hashCode返回独一无二的散列码,会让存储在这个对象的hashtables更好地工作)。
重写hashCode()方法时除了上述一致性约定,还有一下几点需要注意:
- 返回的hash值是int型的,防止溢出。
- 不同的对象返回的hash值应该尽量不同。
- 《Java编程思想》中提到一种情况:“设计hashCode()时最重要的因素就是:无论何时,对同一个对象调用hashCode()都因该产生同样的值。如果在将一个对象用put()添加进HashMap时产生一个hashCode值,而用get()取出时却产生了另一个hashCode值,那么就无法获取该对象。所以如果你的hashCode()方法依赖于对象中易变的数据,用户就要当心了,因为此数据发生变化时hashCode()方法就会生成一个不同的散列码”。
3 System.out.println(new mianshi())打印的是什么?
这题实质上是在考察Object类中的默认方法–toString()。Java中所有对象都是继承自Obejct,自然继承了toString方法,在当使用System.out.println()里面为一个对象的引用时,自动调用toStirng方法将对象打印出来。如果重写了toString()方法则调用重写的toString()方法。这里如果没有对mianshi类的toString()方法进行重写,那么将会输出 mianshi@hashcodea。
4 syn锁与lock锁的区别
4.1 synchronized的相关概念与实现原理
4.1.1synchronized的基本概念
在并发编程中存在线程安全问题,主要原因是:
- 存在共享数据
- 多线程共同操作共享数据
synchronized
是Java提供的一个并发控制的关键字。主要有两种用法,分别是同步方法和同步代码块。也就是说,synchronized
既可以修饰方法也可以修饰代码块。
1 | /** |
被synchronized
修饰的代码块及方法,在同一时间,只能被单个线程访问。
4.1.2 synchronized的实现原理
首先需要使用Javap来反编译以上代码,结果如下(部分无用信息过滤掉了):
1 | public synchronized void doSth(); |
反编译后,我们可以看到Java编译器为我们生成的字节码。在对于doSth
和doSth1
的处理上稍有不同。也就是说。JVM对于同步方法和同步代码块的处理方式不同。对于同步方法,JVM采用ACC_SYNCHRONIZED
标记符来实现同步。 对于同步代码块。JVM采用monitorenter
、monitorexit
两个指令来实现同步。
- 方法级的同步是隐式的。同步方法的常量池中会有一个
ACC_SYNCHRONIZED
标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED
,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。 - 同步代码块使用
monitorenter
和monitorexit
两个指令实现。可以把执行monitorenter
指令理解为加锁,执行monitorexit
理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter
)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit
指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
实现原理总结:同步方法通过ACC_SYNCHRONIZED
关键字隐式的对方法进行加锁。当线程要执行的方法被标注上ACC_SYNCHRONIZED
时,需要先获得锁才能执行该方法。同步代码块通过monitorenter
和monitorexit
执行来进行加锁。当线程执行到monitorenter
的时候要先获得所锁,才能执行后面的方法。当线程执行到monitorexit
的时候则要释放锁。每个对象自身维护这一个被加锁次数的计数器,当计数器数字为0时表示可以被任意线程获得锁。当计数器不为0时,只有获得锁的线程才能再次获得锁。即可重入锁。
4.1.2 monitor是什么?怎么定义的?一个什么标志?在哪里标志的?
我们可以把monitor理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。 Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
4.1.3 synchronized的三种应用方式?
Java中每一个对象可以作为锁,这是synchronized实现同步的基础:
普通同步方法(实例方法),锁是当前实例对象,进入同步代码前要获得当前实例的锁。
======多个线程访问同一个对象的同一个方法======
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
35public class synchronizedTest implements Runnable {
//共享资源
static int i =0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i++;
}
public void run(){
for (int j =0 ; j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
synchronizedTest test = new synchronizedTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
/**
console:
2000
Process finished with exit code 0
============================================================
分析:当两个线程同时对一个对象的一个方法进行操作,只有一个线程能够抢到锁。因为一个对象只有一把锁,一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,就不能访问该对象的其他synchronized实例方法,但是可以访问非synchronized修饰的方法
*/======一个线程获取了该对象的锁之后,其他线程来访问其他synchronized实例方法现象======
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
53public synchronized void method1() {
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public synchronized void method2() {
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final syncTest test = new syncTest();
new Thread(new Runnable() {
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
public void run() {
test.method2();
}
}).start();
}
/**
console:
Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end
Process finished with exit code 0
============================================================
可以看出其他线程来访问synchronized修饰的其他方法时需要等待线程1先把锁释放
*/=一个线程获取该对象的锁之后,其他线程来访问其他非synchronized实例方法现象(去掉method2的锁)=
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
53public synchronized void method1() {
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public void method2() {
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final syncTest test = new syncTest();
new Thread(new Runnable() {
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
public void run() {
test.method2();
}
}).start();
}
/**
console:
Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 2 end
Method 1 end
Process finished with exit code 0
============================================================
当线程1还在执行时,线程2也执行了,所以当其他线程来访问非synchronized修饰的方法时是可以访问的
*/====当多个线程作用于不同的对象====
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//共享资源
static int i =0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i++;
}
public void run(){
for (int j =0 ; j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new synchronizedTest());
Thread t2 = new Thread(new synchronizedTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
/**
console:
Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 2 end
Method 1 end
Process finished with exit code 0
============================================================
因为两个线程作用于不同的对象,获得的是不同的锁,所以互相并不影响
**此处思考一个问题:为什么分布式环境下synchronized失效?如何解决这种情况?**
*/静态同步方法,锁是当前类的class对象,进入同步代码前要获得当前类对象的锁。
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
33public class synchronizedTest implements Runnable {
//共享资源
static int i =0;
/**
* synchronized 修饰实例方法
*/
public static synchronized void increase(){
i++;
}
public void run(){
for (int j =0 ; j<10000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new synchronizedTest());
Thread t2 = new Thread(new synchronizedTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
/**
console:
2000
Process finished with exit code 0
============================================================
由例子可知,两个线程实例化两个不同的对象,但是访问的方法是静态的,两个线程发生了互斥(即一个线程访问,另一个线程只能等着),因为静态方法是依附于类而不是对象的,当synchronized修饰静态方法时,锁是class对象。
*/同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
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
32public class synchronizedTest implements Runnable {
static synchronizedTest instance=new synchronizedTest();
static int i=0;
public void run() {
//省略其他耗时操作....
//使用同步代码块对变量i进行同步操作,锁对象为instance
synchronized(instance){
for(int j=0;j<10000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
/**
console:
2000
Process finished with exit code 0
============================================================
为什么要同步代码块呢?在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。
分析:将synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了instance作为对象外,我们还可以使用this对象(代表当前实例)或者当前类的class对象作为锁。
*/