[面试]Java常见面试题,持续更新…

Java基础

浏览数:72

2020-6-13

一.List集合类

ArrayList和LinkedList的区别

从底层数据结构以及访问、添加、删除的效率分别说明
注意: ArrayList当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间

Vector

Vector也是通过数组实现的,但它支持线程同步,避免同时写引起的不一致性,实现同步需要很高的开销,因此它的访问比ArrayList慢

二.Set 集合类

Set排序子类、HashSet、hashCode()、equals的关系
  • HashSet:依靠HashCode和equals方法判断重复,首先判断哈希值,如果哈希值相同再判断equals;相同哈希值但equals不同的,放在一个bucket里,;它是无序的
  • TreeSet:二叉树原理;有序的,需要实现Comparable接口;
  • LinkedHashSet:继承了HashSet,方法接口与HashSet相同;底层使用LinkedHashMap 来保存所有元素;属于有序,增加顺序为保存顺序

三.Map类

HashMap的底层实现原理

jdk1.7中的HashMap

HashMap是一个数组,数组的每一个元素是一个Entry的链表
每一个Entry对象包括了Keyvaluehash值和指向下一个元素的next

HashMap包括两个构造参数:
1.capacity:当前数组的容量,可扩容,扩容后的大小为当前数组大小的两倍
2.loadFactor:负载因子,默认0.75

HashMap(int initialCapacity, float loadFactor)

threshold:扩容的阈值,等于capacity * loadFactor

JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

(1). HashMap 允许有一个null的key,允许多个value为null

(2). HashMap 是线程不安全的,可通过Collections的synchronizedMap 方法使HashMap 具有线程安全的能力,或者使用ConcurrentHashMap

ConcurrentHashMap
  • ConcurrentHashMap支持并发操作,线程安全
  • 由一个个Segment组成,是一个Segment数组,Segment通过继承ReentrantLock进行加锁,每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全.
  • 一个Segment相当于一个HashMap

构造参数:
concurrencyLevel:并行数,也即segment的个数,默认16,初始化后不可更改或扩容;一个segment同时只允许一个线程操作, 16个segment允许16个线程在各种不同的segment上并发写;

HashMap和HashTable的区别

HashTable是上古版本的遗留类,现在很少使用,

  • 继承自Dictionary类
  • 线程安全,但并发性不如ConcurrentHashMap,因为后者引入了分段锁
  • HashTable不允许key为null
  • HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey,
  • 初始容量和扩容:HashTable在不指定容量的情况下的默认容量为11,而HashMap为16,Hashtable不要求底层数组的容量一定要为2的整数次幂,而HashMap则要求一定为2的整数次幂。 Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍
TreeMap(可排序)
  • 实现SortedMap 接口,能够把它保存的记录根据键排序
  • key 必须实现Comparable 接口或者在构造TreeMap 传入自定义的Comparator
LinkHashMap(记录插入顺序)

它继承HashMap、底层使用哈希表与双向链表来保存所有元素
Entry元素比HashMap多了:
Entry<K, V> before
Entry<K, V> after
before、After是用于维护Entry插入的先后顺序的

大概的图:

多线程相关

四.线程池

为什么使用线程池

1.减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
2.可以根据系统的承受能力,调整线程池中工作线线程的数目,防止消耗过多的内存

newCachedThreadPool
  • 创建一个可根据需要创建新线程的线程池, 线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小
  • 调用 execute 将重用以前构造的线程(如果线程可用)
  • 如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程
newFixedThreadPool
  • 创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程
  • 创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小
  • 线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程
newScheduledThreadPool

创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。

ScheduledExecutorService scheduledThreadPool= Executors.newScheduledThreadPool(3);
scheduledThreadPool.schedule(newRunnable(){
    @Override
    public void run() {
    System.out.println("延迟三秒");
    }}, 3, TimeUnit.SECONDS);
scheduledThreadPool.scheduleAtFixedRate(newRunnable(){
    @Override
    public void run() {
    System.out.println("延迟1 秒后每三秒执行一次");
    }},1,3,TimeUnit.SECONDS);
newSingleThreadExecutor
  • 创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务
  • 如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。
  • 此线程池保证所有任务的执行顺序按照任务的提交顺序执行

五.java线程的生命周期

在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)5 种状态

新建状态 new

当程序使用new 关键字创建了一个线程之后,该线程就处于新建状态,此时仅由JVM 为其分配内存,并初始化其成员变量的值

就绪状态 Runnable

当线程对象调用了start()方法之后,该线程处于就绪状态。Java 虚拟机会为其创建方法调用栈和程序计数器,等待调度运行。

运行状态 Running

如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态。

阻塞状态 Blocked

阻塞状态是指线程因为某种原因放弃了cpu 使用权,暂时停止运行
阻塞的情况分三种:

  1. 等待阻塞: o.wait->等待队列

运行(running)的线程执行o.wait()方法,JVM 会把该线程放入等待队列(waitting queue)
中。

  1. 同步阻塞(lock->lockPool)

运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM 会把该线程放入锁池(lock pool)中。

  1. 其他阻塞(sleep/join)

运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O 请求时,JVM 会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O
处理完毕时,线程重新转入可运行(runnable)状态。

死亡状态 dead

正常结束

  1. run()或call()方法执行完成,线程正常结束。

异常结束

  1. 线程抛出一个未捕获的Exception 或Error。

调用 stop

  1. 直接调用该线程的stop()方法来结束该线程—该方法通常容易导致死锁,不推荐使用。

六.Volatile 关键字

可见性

在访问volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile 变量是一种比sychronized 关键字更轻量级的同步机制。volatile 适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值


简单原理:
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU 缓存中。如果计算机有
多个CPU,每个线程可能在不同的CPU 上被处理,这意味着每个线程可以拷贝到不同的 CPU
cache 中。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache这一步。

屏蔽指令重排序

指令重排序是编译器和处理器为了高效对程序进行优化的手段,它只能保证程序执行的结果是正确的,但是无法保证程序的操作顺序与代码顺序一致。这在单线程中不会构成问题,但是在多线程中就会出现问题。非常经典的例子是在单例方法中同时对字段加入voliate,就是为了防止指令重排序。

public class Singleton {
    private volatile static Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {
        if (singleton == null) { // 1
            synchronized(Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton(); // 2
                }
            }
        }
        return singleton;
    }
} 

实际上当程序执行到2处的时候,如果我们没有使用volatile关键字修饰变量singleton,就可能会造成错误。这是因为使用new关键字初始化一个对象的过程并不是一个原子的操作,它分成下面三个步骤进行:

a. 给 singleton 分配内存
b. 调用 Singleton 的构造函数来初始化成员变量
c. 将 singleton 对象指向分配的内存空间(执行完这步 singleton 就为非 null 了)

如果虚拟机存在指令重排序优化,则步骤b和c的顺序是无法确定的。如果A线程率先进入同步代码块并先执行了c而没有执行b,此时因为singleton已经非null。这时候线程B到了1处,判断singleton非null并将其返回使用,因为此时Singleton实际上还未初始化,自然就会出错。synchronized可以解决内存可见性,但是不能解决重排序问题。

不能保证原子性

举例说明:
以i++为例,其包括读取、操作、赋值三个操作,下面是两个线程的操作顺序

假如说线程A在做了i+1,但未赋值的时候,线程B就开始读取i,那么当线程A赋值i=1,并回写到主内存,而此时线程B已经不再需要i的值了,而是直接交给处理器去做+1的操作,于是当线程B执行完并回写到主内存,i的值仍然是1,而不是预期的2。也就是说,volatile缩短了普通变量在不同线程之间执行的时间差,但仍然存有漏洞,依然不能保证原子性。

七.java的可见性、有序性、原子性

参考文章 : java的可见性、有序性和原子性

八.什么是CAS 乐观锁

参考资料: Java:CAS(乐观锁)

sychronized 实现同步的问题

在JDK 5之前Java语言是靠synchronized关键字保证同步的,该机制存在以下问题:

(1)在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题

(2)一个线程持有锁会导致其它所有需要此锁的线程挂起

(3)如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

悲观锁和乐观锁

独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。
而另一个更加有效的锁就是乐观锁。
所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止
乐观锁用到的机制就是CAS,Compare and Swap。

CAS 原理:

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

CAS存在的问题(了解,说个大概)
  1. ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  1. 循环时间长开销大自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。 当需要修改共享变量的线程增多时,情况会更为严重;
  2. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

九.内存模型相关

说说jvm内存模型

参加个人文章 java内存模型

垃圾回收的算法,各自优缺点

同上

Java 中的对象一定在堆上分配吗?
  • 栈上分配

JVM在Server模式下的逃逸分析可以分析出某个对象是否永远只在某个方法、线程的范围内,并没有“逃逸”出这个范围,逃逸分析的一个结果就是对于某些未逃逸对象可以直接在栈上分配,由于该对象一定是局部的,所以栈上分配不会有问题。

其他java题目

一.java 多态的原理

假设有一个Person类,以及两个子类Boy和Girl
当这三个类加载到虚拟机后,方法区就包含了这三个类的信息,包括各自的方法表
Girl 和 Boy 在方法区中的方法表可表示如下:

方法表中的条目指向的具体的方法地址
如 Girl 继承自 Object 的方法中,只有 toString() 指向自己的实现(Girl 的方法代码,被重写),其余皆指向 Object 的方法代码;其继承自于 Person 的方法 eat() [未重写]和 speak() [被重写]分别指向 Person 的方法实现和本身的实现。

那么父类方法表和子类方法的方法表有什么关系呢?

如果子类改写了父类的方法,那么子类和父类的那些同名的方法仍然共享一个方法表项。(可以理解为这个方法在方法表里的叫法/直接引用还是不变的)
因此,方法在方法表中的偏移量总是固定的,所有继承父类的子类的方法表中,其父类所定义的方法的偏移量也总是一个定值。(可以理解为即使被重写,子类还可以通过和父类的同样直接引用找到该方法)
这样 JVM 在调用实例方法其实只需要指定调用方法表中的第几个方法即可

知道了方法表是怎样,那怎么通过方法表调用方法的过程是怎样呢?

假设代码如下:

class Party {
    void happyHour() {
        Person girl = new Girl();
        girl.speak();
    }
}

(1)在常量池中找到方法调用的符号引用
(2)查看Person的方法表,得到speak方法在该方法表的偏移量(假设为15),这样就得到该方法的直接引用。 
(3)根据this指针得到具体的对象(即 girl 所指向的位于堆中的对象)。
(4)根据对象得到该对象对应的方法表根据偏移量15查看有无重写(override)该方法,如果重写,则可以直接调用(Girl的方法表的speak项指向自身的方法而非父类);如果没有重写,则需要拿到按照继承关系从下往上的基类(这里是Person类)的方法表,同样按照这个偏移量15查看有无该方法。

类的加载过程?

下面代码的执行结果是?

class Person{
    {
        System.out.println("P1");
    }
    static {
        System.out.println("P2");
    }
    public Person(){
        System.out.println("P3");
    }
}
class Student extends Person{
    static {
        System.out.println("S1");
    }

    {
        System.out.println("S2");
    }

    public Student(){
        System.out.println("S3");
    }
}

public class Main {

    public static void main(String[] args) {
        Student s = new Student();
    }
}

答案:P2,S1,P1,P3,S2,S3

作者:猛男落泪为offer