看完这篇,再也不怕面试官问Java基础了!

427 阅读24分钟
1、==equals有什么区别?
  • 对于基本数据类型,==比较的是值,对于引用数据类型,==比较的是内存地址;
  • equals()方法是Object类的方法,所以只能比较用来引用数据类型,不能比较基本数据类型;
  • equals()比较两个对象是否相等时分为两种情况:

​ 情况1:该类没有重写equals()方法,这时equals()等价于==,用于比较内存地址;

​ 情况2:该类已重写equals()方法,这时equals()用于比较两个对象的内容是否相同,比如Stirng类 就已重 写过equals()方法,所以使用Stringequals()方法就是用来比较两个字符串的内 容是否相同。

2、hashcodeequals的关系及应用场景?

我们以 ”类的用途“ 将hashCode()equals()的关系分2种情况来说明:

应用场景:

情况1:不会创建类对应的散列表(哈希表)

​ 这里所说的“不会创建类对应的散列表”是说:我们不会在HashSet, Hashtable, HashMap等这些本质是散列表的数据结构中用到该类。例如:不会创建该类的HashSet集合。

​ 在这种情况下,该类的hashCode()equals() 没有半毛钱关系!equals() 用来比较该类的两个对象是否相等,而hashCode() 则根本没有任何作用,所以,不用理会hashCode()

情况2:会创建类对应的散列表(哈希表)

​ 这里所说的“会创建类对应的散列表”是说:我们会在HashSet, Hashtable, HashMap等这些本质是散列表的数据结构中用到该类。例如:会创建该类的HashSet集合。在这种情况下,该类的“hashCode()equals()是有关系的。

两者关系:

  • 如果两个对象相等,那么它们的hashCode()值一定相同;
  • 如果两个对象hashCode()相等,它们并不一定等。

注:两个对象相等是指通过equals()比较两个对象时返回true

所以在这种情况下,要判断两个对象是否相等,除了要重写equals()之外,还要重写hashCode()方法。HashSet就是利用这两个方法达到去重效果的。

4、Integerint类型比较(自动装箱/拆箱机制)

自动装箱:将基本数据类型用对应的引用数据类型包装起来;

自动拆箱:将引用数据类型转换为对应的基本数据类型。

Integer的缓存范围:-128—127

5、ArrayListLinkedList底层存储结构的原理和区别,各自有什么优点和缺点?
  • 线程安全:ArrayListLinkedList都是线程不安全的,Vector是线程安全的,三个都是List接口实现类;
  • 底层数据结构:ArrayList底层为Object类型的数组,LikedList底层为双向链表;
  • 优缺点:ArrayList查询快,增删慢;LinkedList增删快,查询慢;
  • 插入和删除是否受元素位置影响:

ArrayList底层采用数组存储,所以插入和删除元素受元素位置影响。执行add(E e)方法添加元素时,默认将元素添加到列表的末尾,此时的时间复杂度为O(1);但如果在指定位置i处插入或删除元素的话add(int index , E e),时间复杂度为O(n-i),因为此时集合中第i个元素之后的(n-i)个元素都要向后(插入操作)/向前(删除操作)移一位。

LinkedList底层采用链表存储,所以插入和删除元素不受元素位置影响执行。执行add(E e)方法添加元素时,默认将元素添加到列表的末尾,此时的时间复杂度为O(1);但如果在指定位置i处插入或删除元素的话add(int index , E e),时间复杂度为O(n),因为需要先移动到该位置再插入。

说明:时间复杂度指的就是需要执行几次操作可以达到目的。

  • 是否支持快速随机访问:ArrayList支持,LinkedList不支持。所谓快速随机访问指的就是通过下标快速找到元素,对应get(int index)方法,数组天然支持快速随机访问。
  • 内存空间情况:ArrayList占用空间主要体现在在列表的结尾会预留一定的空间;而LinkedLis空间浪费体现在每个元素节点都要维护一个直接前驱和直接后继,这也是链表的特点。
6、final关键字的作用

final关键字可以修饰类、变量、方法。

修饰类:被final修饰的类不可以被继承,并且该类的成员方法隐式地被为final修饰;

修饰变量:如果变量是基本数据类型,一旦初始化则不能更改;如果变量是引用数据类型,初始化后该引用不能再指向其他对象。

修饰方法:该方法不能被重写,一个类中的private方法都隐式地被final修饰。

7、String str1=”abc”String str2=new String(“abc”)的区别

这道题需要结合上下文考虑

String str1 = “abc”:可能创建一个或者不创建对象,如果”abc”这个字符串在String池里不存在,会在String池里创建一个值为“abc”String对象,然后str1指向这个内存地址。无论以后用这种方式创建多少个值为”abc”的字符串对象,始终只有一个内存地址被分配,之后的都是String的拷贝,Java中称为“字符串驻留”,所有的字符串常量都会在编译之后自动地驻留。

String str2 = new String(“abc”):至少创建一个对象,也可能两个。因为用到new关键字,肯定会在堆上创建一个对象,同时如果这个字符串在String池里不存在,会在池里创建一个String对象“abc”

说明:在JVM里,考虑到垃圾回收(Garbage Collection)的方便,将堆(heap)划分为三部分:young generation(新生代)、old generation(旧生代)、permanent generation(永生代),为了解决字符串重复问题,字符串的生命周期长,存于永生代中。

8、StringStringBufferStringBuilder的区别
  • 可变性:

String类的源码:private final char value[],可以看到,底层使用的是final修饰的数组存储数据,由于被final修饰,所以不可变;

AbstractStringBuilder类的源码:char[] valueStringBufferStringBuilder均继承自AbstractStringBuilder,同样是采用数组存储,但没有被final修饰,所以可变;

  • 线程安全性:

Stirng类被final修饰,不可变,可以看成是常量,所以线程安全;

StringBufferJDK1.0就存在,该类的方法均被synchronized关键字修饰,所以线程安全;

StringBuilderJDK1.5引入,该类的方法内有使用同步锁,所以线程不安全;

  • 性能:

①每次操作String类型的数据都会生成一个新的对象,大量的对象会造成空间浪费,这也是为什么不建议使 用"+"操作字符串,而是使用StringBuilder中的append()方法在末尾追加字符;

StringBuffer使用了同步锁,效率较低;

StringBuilder没有加同步锁,效率高;

  • 使用场景:

①操作字符串不频繁,使用String

②频繁操作字符串且要求线程安全,使用StringBuffer

③频繁操作字符串但不要求线程安全,使用StringBulider

9、什么是反射?为什么需要反射?
  • 什么是反射:

在运行状态中,对于任何一个类,我们都能够知道这个类有哪些方法和属性,对于任何一个对象,我们都能够对它的方法和属性进行调用。我们把这种动态获取对象信息和调用对象方法的功能称之为反射机制。

  • 为什么需要反射:

①提高程序的灵活性;

②屏蔽掉实现的细节,让使用者更加方便好用。

  • 两个概念

①静态编译:在编译时确定类型,绑定对象;

②动态编译:运行时确定类型,绑定对象。

动态编译发挥了Java的灵活性,体现了多态的应用,可以减低耦合性。反射赋予了JVM动态编译的能力。

  • 举例:

JDBC操作数据库的时候,使用配置文件读取数据库的配置信息代替在代码中编写数据库信息,修改配置文件要比修改源代码要灵活很多;

②我们是用的框架大量使用到了反射机制;

③如果需要自己手写框架/组件的时候,需要用到反射,平时一般不用。

10、什么是 Java序列化?什么情况下需要序列化?

序列化:将Java对象转换成字节流的过程;

反序列化:将字节流转换成Java对象的过程。

应用场景:当Java对象需要在网络上传输或者需要持久化存储时,就需要对Java对象进行序列化处理。

注意点:

  • 某个类可以被序列化,则其子类也可以被序列化;
  • 声明为 statictransient 的成员变量,不能被序列化。static 成员变量是描述类级别的属性,transient 表示临时数据;
  • 反序列化读取序列化对象的顺序要保持一致。
11、动态代理是什么?为什么需要动态代理?
12、深拷贝和浅拷贝区别是什么?

基本数据类型:无论深拷贝还是浅拷贝,都是值传递;

引用数据类型:浅拷贝只复制对象的内存地址,而不复制对象本身,新旧对象还是共享同一块内存;深拷贝会另外创造一个一模一样的对象,新旧对象不共享内存,修改新对象不会改变旧对象。

13、HashMapHashtable 有什么区别?

两者都是Map接口的实现类

区别:

  • 线程安全:HashMap线程不安全,Hashtable线程不安全;
  • 效率:HashMap效率比Hashtable效率高;
  • null值的支持:

HashMap允许键和值都为null,但是为null的键只能有一个,可以有多个值为null

Hashtable不允许存在null值,如果put进去一个null值,直接抛出空指针异常;

  • 初始容量:HashMap16Hashtable11
  • 扩容后的大小:HashMap扩容后容量是原来的2倍,Hashtable扩容后容量是原来的2n+1倍。
14、HashMap的实现原理,HashMapConcurrentHashMap哪个是线程安全的,为什么ConcurrentHashMap是线程安全的(实现原理)

HashMap的实现原理参考这篇文章ConcurrentHashMap是线程安全的;实现原理详见面试突击

15、并行和并发有什么区别?

并行:单位时间内,多个任务同时执行;

并发:同一时间段,多个任务都在执行。

举例:并发是一个人同时吃三个苹果;并行是三个人同时吃三个苹果。

16、创建线程有几种方式?

共有四种创建方式:

1)继承Thread类,步骤如下:

  • 定义一个类,并使其继承Thread类;
  • 重写run()方法,run()方法的方法体就是线程要执行的任务;
  • 创建线程实例,调用start()方法启动线程。
  • 代码如下:
package thread;
public class MyThread extends Thread {
 
 @Override
 public void run() {
     for (int i = 0; i < 100; i++) {
         System.out.println(getName() + "---" + i);
     }
 }
 
 public static void main(String[] args) {
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "=====" + i);
         if (i == 50) {
             new MyThread().start();
             new MyThread().start();
         }
     }
 }
}

2)实现Runnable接口,步骤如下:

  • 定义Runnable接口的实现类,并重写Run()方法,也是线程的执行体;
  • 创建该实现类的实例对象,并依此实例对象作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象;
  • 调用start()方法启动线程。
  • 代码如下:
package thread;
public class RunnableThreadTest implements Runnable {
 
 @Override
 public void run() {
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "--" + i);
     }
 }
 
 public static void main(String[] args) {
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "==" + i);
         if (i == 20) {
             RunnableThreadTest rtt = new RunnableThreadTest();
             new Thread(rtt, "线程1").start();
             new Thread(rtt, "线程2").start();
         }
     }
 }
}

实现Runnable接口比继承Thread更为灵活,因为可以实现多个接口,但只能继承一个类。

3)实现Callable接口,步骤如下:

  • 创建Callable接口的实现类,并重写call()方法,该call()方法将作为线程执行体,并且有返回值;
  • 创建实现类的实例对象,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值;
  • 使用FutureTask对象作为Thread对象的target创建并启动新线程;
  • 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
  • 代码如下:
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
 * 创建Callable接口的实现类
*/
public class CallableThreadTest implements Callable {
 /**
     * 重写run方法,该方法作为线程执行体,并且有返回值
  */
 @Override
 public Integer call() throws Exception {
     int i = 0;
     for (; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "===" + i);
     }
     return i;
 }

 public static void main(String[] args) {
     //创建实现类的实例对象
     CallableThreadTest ctt = new CallableThreadTest();
     //使用FutureTask类来包装Callable对象,FutureTask对象封装了call()方法的返回值
     FutureTask<Integer> ft = new FutureTask<>(ctt);
     for (int i = 0; i < 100; i++) {
         System.out.println(Thread.currentThread().getName() + "==" + i);
         if (i == 20) {
             //使用FutureTask对象作为Thread对象的target参数创建并启动线程
             new Thread(ft, "有返回值的线程").start();
         }
     }
     try {
         //调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
         System.out.println("子线程的返回值:" + ft.get());
     } catch (InterruptedException e) {
         e.printStackTrace();
     } catch (ExecutionException e) {
         e.printStackTrace();
     }
 }
}
17、Runnable接口和Callable接口有什么区别

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

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

这是很有用的一个特性,因为多线程充满着未知性,某个线程是否执行了?执行了多久?某个线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而Callable+Future/FutureTask却可以获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务,非常有用的功能。

18、线程有哪些状态?

Java线程具有五中基本状态:

新建状态(New):当线程对象被创建后,就进入了新建状态,如:Thread t = new MyThread();

就绪状态(Runnable):当调用线程对象的start()方法t.start(),线程进入就绪状态。处于就绪状态的线程,只是说明已经做好了准备,随时等待CPU调度执行,并不是执行了start()此线程立即就会执行;

运行状态(Running):当CPU调度处于就绪状态的线程,该线程才得以真正执行,即进入到运行状态;

注:就绪状态是转化到运行状态的唯一入口,即线程要想进入运行状态执行,首先必须处于就绪状态。

阻塞状态(Blocked):运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进程处于阻塞状态。直到其再次进入到就绪状态,才有机会被CPU调用进入运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

  • 等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
  • 同步阻塞:线程获取synchronized同步锁失败(锁可能被其它线程所占用)时,进入同步阻塞状态;
  • 其他阻塞:调用线程的sleep()join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()时状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束其生命周期。

19、wait()方法和sleep()方法有什么区别?
  • wait()属于Object类的方法,sleep()属于Thread类的方法;

  • wait()释放锁,sleep()不释放锁;(等待是主动行为,会释放锁;睡着了就忘了释放锁,)

  • sleep()常用于使线程暂停执行,而wait()多用于线程间通信;

  • 调用wait(),需要其他线程调用同一对象的notify()notifyAll()后线程才苏醒,或者使用wait(long timeout)超时后线程会自动苏醒;执行sleep(long millis),时间到后线程自动苏醒;

  • 两个方法都可以使线程暂停执行。

20、线程的yield()join()的作用

1)Java线程调度的基本知识:

①在各种各样的线程中,JVM必须实现一个基于优先级的调度程序。这意味着Java程序中的每一个线程都被分配到了一定的优先级,优先级使用定义好的一个正整数表示。优先级可以被开发者改变,但JVM也不会改变优先级。

②优先级的设置很重要,因为JVM和底层操作系统之间的约定是操作系统必须选择高优先级的Java线程运行。所以我们说Java实现了一个基于优先权的调度程序。这意味着当一个有高优先级的线程到来时,无论低优先级的线程是否在运行,都会中断它。但这个约定对于底层操作系统来说并不总是这样,操作系统有时可能会选择运行一个更低优先级的线程。

2)线程的优先级问题

理解线程优先级是多线程学习很重要的一步,尤其是了解yield()方法的工作过程:

①当线程的优先级没有指定时,所有线程都携带普通优先级;

②优先级可以用从1到10的范围指定,10是最高优先级,1是最低优先级,5是普通优先级;

③优先级最高的线程在执行时被给予优先权;

④与在线程池中等待运行机会的线程相比,当前正在运行的线程可能总是拥有更高的优先级;

⑤由调度程序决定哪一个线程被执行;

t.setPriority()用来设定线程的优先级;

⑦在线程start()方法被调用之前,线程的优先级应该被设定;

⑧可以使用常量,如MIN_PRIORITY,MAX_PRIORITY,NORM_PRIORITY来设定优先级。

3)yield()方法

①该方法属于Thread类的方法,yield翻译过来为投降、让步的意思。所以yield()方法被很多人翻译成线程让步,顾名思义,就是说当一个线程使用了这个方法之后,它就会把自己CPU执行权让出来,让自己或者其它的线程运行;

②该方法的作用就是:使当前线程从运行状态变为就绪状态。CPU会从处于就绪状态的线程选择执行,也就是说,刚刚的那个线程还是有可能会被再次执行到的,并不是说一定会执行其他线程而该线程不会执行。

4)join()方法

join()方法可以把指定的线程加入到当前线程,可以将两个交替执行的线程转换为按顺序执行。比如在线程B中调用了线程A的join()方法,直到线程A执行完毕后,才会继续执行线程B。

21、线程的notify()nofityAll()有什么区别?

1)两个概念:锁池和等待池

①锁池:假设线程A已经拥有了某个对象(不是类)的锁,这时其它线程想要调用这个对象的某个synchronized方法或代码块,由于这些线程在进入对象的synchronized方法或代码块之前必须先获得该对象的锁,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。

②等待池:假设线程A调用某个对象的wait()方法,这时线程A就会释放该对象的锁,进入到该对象的等待池中。

2)notify()notifyAll()的区别

①如果线程调用了对象的wait()方法,那么该线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

②当线程调用了对象的notifyAll()方法或notify()方法后,被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用notify()只有一个线程会由等待池进入锁池,而notifyAll()会将该对象等待池内的所有线程移动到锁池中,进行锁竞争;

说明:notifyAll()用于唤醒所有wait线程;notify()只能随机唤醒一个wait线程。

③优先级高的线程竞争到对象锁的概率大。假若某线程没有竞争到该对象锁,则它还会留在锁池中。只有线程再次调用wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized方法或代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

综上,所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池,notifyAll()会将全部线程由等待池移到锁池参与锁的竞争,竞争成功继续执行,竞争失败则留在锁池等待锁被释放后再次参与竞争,而notify()只会唤醒一个线程。

3)为什么notify()可能会导致死锁,而notifyAll()则不会

notify()只唤醒一个正在等待的线程,当该线程执行完会释放该对象的锁,如果没有再次执行notify()方法,则其它正在等待的线程会一直处于等待状态,不会被唤醒进入该对象的锁池,就会发生死锁。但notifyAll()则不会出现死锁问题。

22、创建线程池有哪几种方式?

Java通过Executors提供四种创建线程池的方式,分别为:

newCachedThreadPool:创建一个可缓存的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收的线程,则新建线程;

newFixedThreadPool :创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待;

newScheduledThreadPool: 创建一个定长线程池,支持定时及周期性任务执行;

newSingleThreadExecutor :创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO,LIFO,,优先级)执行。

23、生成线程池方法的入参大概有哪几个核心参数?每个参数代表什么意思?

线程池部分的内容具体参考这篇文章

24、volatile关键字的作用

作用:

①保证变量可见性,被volatile修饰的变量,如果值发生改变,其他线程立马可见,避免出现脏读;

②禁止指令重排序,指令重排序发生在多线程中,指的是代码的执行顺序可能会经过编译器的优化发生改变。比如懒汉式单例中的实例对象就需要volatile修饰,否则可能出现实例化两个对象。

注意:volatile关键字不能保证变量的原子性。

  • 为什么会出现脏读?

Java内存模型规定变量都是存在主存当中,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。这就有可能出现A线程修改了变量的值,而B线程还不知道,造成B线程还在使用A线程修改之前的变量值,这时B线程使用到的就是脏数据。

②为了解决这一问题,就需要把变量声明为volatile,表示告诉JVM,这个变量是不稳定的,每次使用它都要从主存中进行读取。主存是共享区域,操作主存,其他线程都可见。

25、synchronized关键字的作用

作用:synchronized表示同步的意思,被synchronized修饰过的方法或代码块在同一时刻只能有一个线程在执行。

用法:

①修饰非静态方法:使用的锁是this;

public synchronized void method() {}

②修饰代码块:使用的锁由自己决定,只要是对象就可以;

Object obj = new Object()
    
synchronized (obj){
//......
}

③修饰静态方法:使用的锁是该方法所属类的字节码文件。

public static synchronized void sale() {}
26、synchronized锁升级的过程

首先来了解相关锁的概念:

自旋锁(CAS): 让不满足条件的线程等待一会儿看能不能获得锁,通过占用CPU的时间来避免线程切换带来的开销。自旋等待的时间或次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。在JDK1.6之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么JVM就会认为这次自旋很有可能会再次成功,进而将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

偏向锁: 大多数情况下,锁总是由同一个线程多次获得。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,偏向锁是一个可重入的锁。如果锁对象头的Mark Word里存储着指向当前线程的偏向锁,无需重新进行CAS操作来加锁和解锁。当有其他线程尝试竞争偏向锁时,持有偏向锁的线程(不处于活动状态)才会释放锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定进而升级为轻量级锁。

重量级锁: 通过对象内部的监视器(monitor)实现,monitor本质是依赖底层操作系统的MutexLock实现的,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不使用自旋,不会消耗CPU。但是线程会进入阻塞等待直到被其他线程唤醒,响应时间缓慢。

轻量级锁: 减少无实际竞争情况下,使用重量级锁产生的性能消耗。JVM会在当前线程的栈桢中创建用于存储锁记录的空间LockRecord,将对象头中的Mark Word复制到LockRecord中并将LockRecord中的Owner指针指向锁对象。然后线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,成功则当前线程获取到锁,失败则表示其他线程竞争锁,当前线程则尝试使用自旋的方式获取锁。自旋获取锁失败则锁膨胀升级为重量级锁。

27、什么是死锁?

线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法正常执行下去。

如下图所示,线程A持有资源2,线程B持有资源1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。