JAVA基础面试

JDK1.8、11新特性

HaspMap相关

HashMap占多少内存

根据对象=对象头+成员变量+对齐填充,HashMap的初始对象大小为:
hashmap:头部(8)+int(4*4)+float(4)+table数组引用(4)+entrySet引用(4)+keySet引用(4)+values引用(4)+padding(4)=48字节
table:头部(8+4)+长度(4)=16字节
总共:64个字节。

null值与无序

HashMap允许null作为键值,当null作为键时,存放在0位置;
由于HashMap是根据Key的hashcode进行存放,所以是无序的,如果想要有序则使用LinkedHashMap:

  • LinkedHashMapHashMap的子类,内部有一个双向链表维护键值对的顺序,每个键值对既位于哈希表中,也位于双向链表中。

数据结构

HashMap底层是:数组+链表/红黑树
HashMap默认是一个容量为16的数组,当进行数据存储时,会根据键对应的hashcode选择存放的位置,此时总会出现不同的键但是HashCode一致,需要存放的位置也一样,导致出现哈希冲突,一般的解决方案有:

  • 再哈希:就是使用另外的Hash算法再次运算;
  • 开放寻址法:即将发生冲突的位置也加入到hash运算中,得到新的地址;
  • 链地址法:就是在数组每个节点存放的是一个链表,也叫拉链法;
  • 公共溢出区:就是将发生冲突的都放入公共溢出区中;

HashMap就是使用的链地址法,每个数组位置存放一个链表。
由于数组时,查询一个数据的时间复杂度是O(1),只要根据hashcode算出对应位置,直接获取即可;
当数组节点存放链表时,查询的时间复杂度就会和链表的长度有关,变为O(n),如果链表过长,查询就会变慢;
所以在JDK1.8版本加入了红黑树(二叉平衡树),当链表长度超过8时,就会转为红黑树,根据红黑树特性,查询的时间复杂度就变为O(logN)

存值过程

hashmap.png

  • 先判断数组是否存在,长度是否为空,如果为空就执行初始化,得到默认容量为16的数组;
  • 然后调用哈希函数获取Key对应的hash值,再根据(n-1) & hash计算其数组下标;
    • 如果下标位置没有数据,则直接存入;
    • 如果下标位置有数据:
      • 如果与要存的key一样,则替换其value即可;
      • 如果不一样,则判断当前是链表还是红黑树
        • 如果是链表,则进行尾插法,放入链表最后;
          • 如果链表长度超过8,就把链表转成红黑树;
        • 如果是红黑树,则直接插入树中;
          • 长度低于6,就把红黑树转回链表;
  • 如果数组使用量超过阈值0.75,调用resize方法进行数组扩容。

为什么是(n-1) & hash?
首先,hashMap的数组容量是2的n次幂,然后对hashcode进行取余操作时,会发现余数是位运算右移的结果;
根据2的n次幂发现每次右移的都是n-1位,所以表达式为:(n-1) & hash

头插法和尾插法

在JDK1.7之前使用的是头插法,由于在多线程扩容时,可能会出现环,比如本来是[3,1],线程1和2都执行扩容,线程1扩容完结果变为[1,3],线程2执行是就会出现环。
在JDK1.8之后改为尾插法,这样可以保证扩容后,依然会哈希冲突的数据,进入链表的前后顺序不变。

扩容

创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。
结点在新数组中的位置只有两种:原下标位置原下标+旧数组的大小

为什么是线程不安全

当多个线程同时进行put操作时,可能发生值覆盖(JDk1.7之前还可能发生死循环等问题),所以不是线程安全的。
HashTable是线程安全的,它的方法是Synchronize修饰的,通过锁实现了线程安全。

ConcurrentHashMap

ConcurrentHashMap为什么是线程安全,其实就是它的加锁方式(JDK1.7和1.8的区别):
总结:

  • 1.7: Segment + HashEntry + ReentrantLock ,锁粒度为Segment;
  • 1.8: HashEntry + synchronized + CAS + 红黑树 ,锁粒度为HashEntry;

锁的结构不同:
  在JDK1.7中,是基于Segment+HashEntry数组实现的。Segment是Reentrant的子类,而其内部也维护了一个Entry数组,这个Entry数组和HashMap中的Entry数组是一样的。所以说Segment其实是一个锁,可以锁住一段哈希表结构,而ConcurrentHashMap中维护了一个Segment数组,所以是基于分段锁实现的。
  在JDK1.8中,采用synchronized+CAS+红黑树来实现的。锁的粒度也从段锁缩小为结点锁
存值过程不同:
  在JDK1.7中,要进行两次定位,先对Segment进行定位,再对其内部的数组下标进行定位。定位之后会采用自旋锁+锁膨胀的机制进行加锁,也就是自旋获取锁,当自旋次数超过64时,会发生膨胀,直接陷入阻塞状态,等待唤醒。并且在整个put操作期间都持有锁。
  在JDK1.8中只需要一次定位,并且采用CAS+synchronized的机制。如果对应下标处没有结点,说明没有发生哈希冲突,此时直接通过CAS进行插入,若成功,直接返回。若失败,则使用synchronized进行加锁插入。

扩容过程

  • 先传入一个k和v的键值对,不可为空(HashMap是可以为空的),如果为空就直接报错。
  • 接着去判断table是否为空,如果为空就进入初始化阶段。
  • 如果判断数组中某个指定的桶是空的,那就直接把键值对插入到这个桶中作为头节点,而且这个操作不用加锁。
  • 如果这个要插入的桶中的hash值为-1,也就是MOVED状态(也就是这个节点是forwordingNode),那就是说明有线程正在进行扩容操作,那么当前线程就进入协助扩容阶段。
  • 需要把数据插入到链表或者树中,如果这个节点是一个链表节点,那么就遍历这个链表,如果发现有相同的key值就更新value值,如果遍历完了都没有发现相同的key值,就需要在链表的尾部插入该数据。插入结束之后判断该链表节点个数是否大于8,如果大于就需要把链表转化为红黑树存储。
  • 如果这个节点是一个红黑树节点,那就需要按照树的插入规则进行插入。
  • put结束之后,需要给map已存储的数量+1,在addCount方法中判断是否需要扩容。

StringBuffer和StringBuilder

抽象类与接口

线程与锁

线程安全与死锁

什么是线程安全

  所谓的线程安全,指的是当多个线程访问某一个类(对象或方法)时,对象对应的公共数据区始终都能表现正确,那么这个类(对象或方法)就是线程安全的。
也就是说,多个线程同时访问某一个方法,最后执行结果与单线程运行的结果一致,则就是线程安全。

如何保证线程安全?
java常见的就是加锁。

死锁的四个条件

什么是死锁?
  死锁就是两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

产生死锁的四个条件

  1. 互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  2. 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  3. 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放)。
  4. 循环等待条件: 若干进程间形成首尾相接循环等待资源的关系。

如何避免死锁
一般的解决方案分为三种:

  1. 顺序加锁:按照顺序加锁,防止错乱的加锁导致循环等待;
  2. 锁超时:尝试获取锁的时候加一个超时时间,如果到时间还是无法获取,则回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。
  3. 死锁检测:当无法获取锁时,增加判断,是不是此时该锁的持有者需要请求自己所持有的资源;

锁分类

公平锁与非公平锁

  公平锁是指多个线程按照申请锁的顺序来获取锁,新来的线程看到锁的等待队列不为空,则直接入队。
  非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,新的线程来了之后,先去尝试获取锁,获取失败再入队。

乐观锁与悲观锁

  乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制(一般是cas)来验证数据是否存在冲突(一般实现方式是通过加版本号然后进行版本号的对比方式实现);
  悲观锁是基于一种悲观的态度类来防止一切数据冲突,它是以一种预防的姿态在修改数据之前把数据锁住,然后再对数据进行读写,在它释放锁之前任何人都不能对其数据进行操作。

Synchronized

  Synchronized是实现线程同步的关键字,它所表示的是一个非公平的重量级锁
  Synchronized一般是在方法或者同步代码块上使用,在方法上时,最终是通过ACC_SYNCHRONIZED标志,来告诉JVM这是一个同步方法,进入需要对锁的计数器加1,方法结束后计数器-1;
  如果是在同步代码块上,就是通过monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,同样在进入时锁计数器+1,退出释放锁时减一;

实现原理

  Synchronized底层是通过对象头里面的关键字Mark Word信息实现的。
  在Mark Word中,记录了锁的状态,持有锁的线程ID等信息;

加锁流程

当线程A来竞争锁:

  • 如果竞争成功,则持有锁,MarkWord中的所有者信息就为当前线程的ID,锁计数+1
  • 如果竞争失败,则进入阻塞队列

当锁释放了,就会从阻塞队列中唤醒线程A,线程A再次竞争如果成功,则持有锁;
当线程A执行wait()方法时,则释放锁,并进入等待队列,等待被唤醒;
当调用了notify()或者notifyAll()时,线程A唤醒,转入阻塞队列

为什么是非公平

原因大致分为两点:

  1. 当持有锁的线程释放锁时,该线程会执行以下两个重要操作:

    • a:先将锁的持有者 owner 属性赋值为 null
    • b:唤醒等待链表中的一个线程(假定继承者)。
      在a和b之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁。
  2. 当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。
    它是根据排队策略(QMode)的设置进行不同的选择,阻塞队列中维护了两个链表,一个_cxq,一个_EntryList:
    根据排队策略的设置,会决定是从_cxq中取还是_EntryList,或者是把_cxq的头放入_EntryList的头部还是尾部等,所以最终被唤醒的不一定是最早进入阻塞队列的。

锁优化

主要的优化就是引入:偏向锁、轻量级锁、自旋锁、自适应自旋、锁消除、锁粗化。

偏向锁:
适用于只有一个线程获取锁,它没有释放的步骤,加锁、解锁不需要消耗额外的资源;

  1. 首先检查对象头Mark Word中锁标记是否是偏向锁;
  2. 检查对象头中记录的线程ID是否是当前线程的ID,如果是说明当前线程已经获得过锁,当前线程将再次获得锁,可以执行同步代码;
  3. 如果对象头中的线程ID不是当前线程的ID,则通过CAS操作替换成当前线程的ID,如果替换成功意味着当前线程获得了锁,可以执行同步代码;
  4. 如果步骤3的CAS操作失败,则意味着已经有别的线程获得了锁,针对这个锁出现了竞争,偏向锁转为轻量级锁,被挂起的线程被唤醒,线程将按照轻量级锁的机制竞争锁;

轻量级锁:
轻量级锁的性能介于偏向锁与重量级锁之间,在存在锁竞争的情况下,不需要让线程在阻塞与唤醒状态间切换。
其实它的获取步骤主要是在偏向锁时发生了锁竞争,则为了节省开销,直接自旋等待,不需要进入阻塞队列;

Synchronized与Lock

  1. synchronized是一个关键字,而lock是一个接口(里面支持lock、lockInterruptibly、tryLock、unlock、newCondition等方法)。
  2. synchronized是隐式的加锁,lock是显示的加锁。
  3. synchronized可以作用在方法和代码块上,而lock只能作用在代码块上。
  4. synchronized是阻塞式加锁,而lock中的trylock支持非阻塞式加锁。
  5. synchronized没有超时机制,而lock中的trylcok可以支持超时机制。
  6. synchronized不可中断,而lock中的lockInterruptibly可中断的获取锁。
  7. synchronized采用的是monitor对象监视器,lock的底层原理是AQS。
  8. synchronized只有一个同步队列和一个等待队列,而lock有一个同步队列,可以有多个等待队列。
  9. synchronized是非公平锁,而lock可以是公平锁也可以是非公平锁。
  10. synchronized用object的notify方法进行唤醒,而lock用condition进行唤醒。
  11. lock有ReadWriteLock支持并发读。

volatile

  volatile也是实现线程之间参数同步的关键字。主要是保证了参数的可见性
  线程工作时,会将用到的参数变量从主内存拷贝到工作内存中,当线程执行完毕的时候,再将工作内存总的变量写入到主内存中。并且在线程执行时,只要操作之间没有数据依赖性,不影响最终结果,指令是可以重排序的。
  在多线程的情况下,线程是并行的,所以对同一变量的修改顺序是无序的,指令可能发生重排,最终无法实现线程安全。

  使用了volatile修饰后,当修改变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。当一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存读取共享变量

内存屏障

  volatile是利用了内存屏障,CPU理论上支持4中内存屏障:

  1. LoadLoad:禁止读和读的重排序。
  2. StoreStore:禁止写和写的重排序。
  3. LoadStore:禁止读和写的重排序。
  4. StoreLoad:禁止写和读的重排序。

  通过设置内存屏障,来防止指令的重排序,从而防止读写指令的顺序发生变化,比如:

  1. 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。
  2. 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后的读操作重排序。
  3. 在volatile读操作的后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile读操作不会和之后的读操作、写操作重排序。

线程池

默认提供的线程池有四种类型:

  1. newCachedThreadPool:创建一个可缓存的线程池,如果线程池长度超过处理需求,可灵活回收空闲线程,若无可回收,则新建线程。
  2. newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待;
  3. newScheduledThreadPool:创建一个定长线程池,支持定时及周期性任务执行;
  4. newSingleThreadExecutor:创建一个单线程化的线程池,它只会唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO,LIFO,优先级)执行

核心参数解析

corePoolSize:线程池核心线程大小
maximumPoolSize:线程池最大线程数量
keepAliveTime:多余的空闲线程存活时间
unit:空闲线程存活时间单位
workQueue:工作队列
threadFactory:线程工厂
handler:拒绝策略

  • AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。(默认这种);
  • DiscardPolicy:丢弃任务,但是不抛出异常;
  • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程) 。也就是当任务被拒绝添加时,会抛弃任务队列中最旧的任务也就是最先加入队列的,再把这个新任务从队尾添加进去,等待执行;
  • CallerRunsPolicy:谁调用,谁处理。由调用线程(即提交任务给线程池的线程)处理该任务,如果线程池已经被shutdown则直接丢弃;

threadpool.png

JVM内存与类加载

类加载流程

大致分为5步:

  1. 加载
    • 通过全类名获取定义此类的二进制字节流;
    • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构;
    • 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口;
  2. 验证
    • 文件格式验证,即是否符合class文件要求;
    • 元数据验证,即是否符合java语义;
    • 字节码验证,主要是验证控制流、逻辑语义等防止危害jvm;
    • 符号引用验证,保证解析正常进行;
  3. 准备
    • 正式为类变量(即静态变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
  4. 解析
    • 将常量池内的符号引用替换为直接引用的过程;
    • 主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。
  5. 初始化
    • 类加载的最后一步,这一步JVM才开始真正执行类中定义的代码,完成赋值等;

双亲委派模型

JVM的类加载机制为:双亲委派机制;
默认的加载器分为:

  1. 启动类加载器(Bootstrap classLoader):主要负责加载java的lib库
  2. 扩展类加载器(ExtClassLoader):主要负责加载java的扩展目录
  3. 系统类加载器(AppClassLoader):主要负责项目路径下的lib加载;
  4. 自定义加载器(ConsumerClassLoader):自定义加载路径下的类;

加载过程

需要加载一个类时,如果有自定义加载器,则自定义加载器不加载,交给系统类加载器(或者叫应用类加载器);
同理,系统类加载器不加载,先让扩展类加载器去尝试加载;
扩展类也不直接加载,先让启动类加载器去尝试加载,如果启动类加载器没有加载到,再自己尝试,然后逐级向下;

好处

  1. 防止jdk核心代码被修改,比如自己写的String类,不能覆盖JDK里面的String类;
  2. 防止重复加载,当父加载器已经加载了该类时,子加载器无需再加载,保证被加载类的唯一性;

内存结构

JVM内存结构可以分为两块:

  1. 线程私有:只有当前线程能访问数据的区域,线程之间不能共享,线程独享区随线程的创建而创建,随线程的销毁而被回收
    • 程序计数器:程序计数器会记录当前线程要执行指令的内存地址,不会发生内存溢出;
    • 虚拟机栈:每一个线程都会对应一个虚拟机栈,线程中的每个方法都会创建一个栈帧,存放本次方法执行过程中所需要的所有数据。
    • 本地方法栈:维护非Java语句执行过程中产生的数据;
  2. 线程共享:所有线程都可以访问的区域,当线程被销毁的时候,共享区的数据不会立即回收,需要等待达到垃圾回收的阈值之后才会进行回收。
    • :此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存;也是GC主要发生的区域;
      • 新生代:新创建的对象都是从新生代分配内存,新生代由EdenSpace(伊甸园区)和两块相同大小的Survivor Space(幸存者区,通常又称S0和S1或From和To)构成;
        1. 新生代对象在经历每次GC的时候,如果没有被回收,则对象的年龄+1。当年龄超过阈值(默认15)的时候,便会进入老年代。
      • 老年代:存放经过多次新生代GC仍然存活的对象,新建的对象也有可能直接进入老年代,主要有两种情况:
        1. 大对象;
        2. 大的数组对象,且数组中无引用外部对象。
    • 方法区:此区域存放了对象相关信息,包括全限定名、除了常量以外的所有类(静态)变量、常量池(常量数据以及对其他类型的符号引用)等;

GC算法

如何判断对象存活

主要分为两种:引用计数算法可达性分析算法

  1. 引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器为0的对象就是可回收的。但是无法处理循环引用问题。
  2. 可达性分析:从GC Root开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

引用分类

  1. 强引用
    Object o = new Object()就是一种强引用关系。无论任何情况下,只要强引用关系还存在,垃圾回收器就不会回收掉被引用的对象。
  2. 软引用 SoftReference
    即有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  3. 弱引用 WeakReference
    即非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  4. 虚引用PhantomReference
    这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。

常见算法

  1. 复制算法:将可用内存按容量分成大小相等的两块,每次只使用其中的一块。当这一块内存用完,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。新生代的幸存者区就是使用的复制算法;
    • 缺点:只能使用内存的一半,代价高;
  2. 标记-整理算法:对对象进行标记,然后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存;
  3. 标记-清除算法:标记所有需要回收的对象,标记完成后统一回收所有被标记的对象;
    • 缺点:效率不高,且产生大量不连续的内存碎片;
  4. 分代收集:根据堆的不同部分,使用不同的垃圾回收算法;

参数调优

参数 说明
-XX:+AlwaysPreTouch JVM启动时分配内存,非使用时再分配
-XX:ErrorFile= filename 崩溃日志
-XX:+TraceClassLoading 跟踪类加载信息
-XX:+PrintClassHistogram 按下Ctr+ Break后,打印类的信息
-Xmx -Xms 最大堆和最小堆
-xx:permSize, -xx:metaspaceSize 永久代/元数据空间
-XX:+HeapDumpOnOutOfMemoryError 0OM时导出堆到文件
-XX:+HeapDumpPath OOM时堆导出的路径
-XX:+OnOutOfMemoryError JVM启动时分配内存,非使用时再分配在OOM时,执行一个脚本
-Xloggc:/opt/gc.log 通过这个参数可以将GC日志进行输出到指定文件