医健数联 Java开发 一面 面经
1. HashMap的底层实现原理,扩容机制是怎样的?
底层结构:
HashMap在JDK 1.8中采用数组+链表+红黑树的结构。初始容量是16,负载因子默认0.75。当链表长度超过8且数组长度大于等于64时,链表会转换为红黑树;当红黑树节点少于6个时会退化回链表。
put操作流程:
首先对key进行hash运算,通过hash值和数组长度减1做与运算定位到数组下标。如果该位置为空就直接插入;如果不为空,先判断key是否相同,相同就覆盖value;如果是树节点就按红黑树方式插入;如果是链表就遍历链表,找到相同key就覆盖,没找到就插入到链表尾部。插入后判断链表长度是否需要树化,最后判断是否需要扩容。
扩容机制:
当元素个数超过阈值(容量×负载因子)时触发扩容。扩容时创建一个容量翻倍的新数组,然后遍历旧数组的每个位置。如果只有一个节点就重新计算位置放入新数组;如果是链表,会拆分成两条链表,一条保持原位置,另一条移到原位置加旧容量的位置;如果是红黑树也会拆分,必要时退化为链表。
为什么容量是2的幂次:
这样做有两个好处。一是计算数组下标时可以用位运算代替取模运算,性能更高。二是扩容时判断元素新位置很简单,只需要看hash值的某一位是0还是1,是0就留在原位置,是1就移到原位置加旧容量的位置。
线程安全问题:
HashMap不是线程安全的。并发put可能导致数据丢失,JDK 1.7扩容时多线程操作可能形成环形链表导致死循环。JDK 1.8虽然改为尾插法避免了环形链表,但仍然不安全。需要线程安全可以用ConcurrentHashMap。
2. synchronized锁升级过程,能否降级?
锁的四种状态:
synchronized有四种锁状态:无锁、偏向锁、轻量级锁、重量级锁。这四种状态会随着竞争情况逐渐升级,但一般不会降级。
偏向锁:
偏向锁是为了优化只有一个线程访问同步块的场景。第一次获取锁时,会用CAS操作将线程ID写入对象头。后续该线程再进入时只需要检查对象头的线程ID是否是自己,是的话就直接进入,不需要任何CAS操作,性能最好。如果有其他线程尝试获取锁,偏向锁会被撤销,升级为轻量级锁。
轻量级锁:
轻量级锁适用于多个线程交替访问、没有实际竞争的场景。线程在栈帧中创建锁记录,用CAS将对象头的Mark Word复制到锁记录,然后CAS将对象头指向锁记录。如果成功就获得轻量级锁,如果失败就自旋尝试获取。自旋一定次数后还没获取到,就升级为重量级锁。
重量级锁:
重量级锁使用操作系统的互斥量实现。未获得锁的线程会被阻塞,从用户态切换到内核态。持有锁的线程释放后会唤醒等待的线程。这种方式性能开销大,但避免了CPU空转。
能否降级:
在JDK 15之前,正常情况下锁不会降级,只在垃圾回收的安全点才可能降级。偏向锁可以被撤销回到无锁状态。JDK 15之后默认禁用了偏向锁,因为在高并发场景下偏向锁的撤销成本反而会降低性能。
锁优化:
JVM还会做锁粗化和锁消除优化。锁粗化是把多个连续的加锁解锁操作合并为一次。锁消除是通过逃逸分析发现对象不会被其他线程访问时,直接消除锁操作。
3. CAS的原理、优缺点及ABA问题
CAS原理:
CAS是Compare And Swap的缩写,意思是比较并交换。它包含三个操作数:内存位置、期望值和新值。操作时先比较内存位置的值是否等于期望值,如果相等就更新为新值,不相等就不更新。整个过程是原子操作,由CPU指令保证。
Java中通过Unsafe类调用底层的CAS指令。比如AtomicInteger的自增操作,会在一个循环里不断尝试CAS,读取当前值,计算新值,然后CAS更新。如果CAS失败说明有其他线程修改了值,就继续循环重试,直到成功为止。
CAS优点:
第一是无锁化,性能高。不需要线程阻塞和唤醒,避免了上下文切换的开销。第二是不会死锁,因为没有锁的持有和等待。第三是减少了用户态和内核态的切换。
CAS缺点:
第一个问题是ABA问题。假设线程1读取值A准备改为B,这时线程2把A改成B又改回A,线程1的CAS会成功,但实际上中间状态已经变化了。解决办法是使用版本号,比如AtomicStampedReference,每次更新都会增加版本号,这样即使值相同但版本号不同也会CAS失败。
第二个问题是自旋开销。如果竞争激烈,线程会一直循环重试,浪费CPU资源。
第三个问题是只能保证单个变量的原子性。如果要同时更新多个变量,可以把它们封装成一个对象,用AtomicReference来操作。
适用场景:
CAS适合竞争不激烈、读多写少、简单原子操作的场景,比如计数器、标志位等。
4. ArrayList和LinkedList的区别,使用场景
底层结构:
ArrayList底层是动态数组,默认初始容量10。LinkedList底层是双向链表,每个节点包含数据、前驱指针和后继指针。
性能差异:
随机访问方面,ArrayList通过数组下标直接访问,时间复杂度O(1)。LinkedList需要从头或尾遍历到指定位置,时间复杂度O(n)。
插入删除方面,ArrayList在尾部插入是O(1),但在中间插入需要移动后面的元素,时间复杂度O(n)。LinkedList插入删除只需要修改指针,但定位到指定位置需要O(n),所以总体也是O(n)。
内存占用方面,ArrayList是连续内存空间,缓存友好。LinkedList每个节点都有额外的指针开销,而且内存不连续。
扩容机制:
ArrayList容量不够时会扩容,新容量是旧容量的1.5倍,然后把旧数组的元素复制到新数组。LinkedList不需要扩容,每次添加元素都是创建新节点。
使用场景:
ArrayList适合频繁随机访问、遍历操作多、尾部插入删除、数据量可预估的场景。比如数据展示列表,可以预先分配容量避免扩容。
LinkedList适合频繁在头部或中间插入删除、实现队列或双端队列、不需要随机访问的场景。比如实现任务队列,可以在头部和尾部高效地添加删除元素。
实际开发中,90%的场景用ArrayList就够了,因为它内存连续,缓存友好,性能更好。只有确实需要频繁头部操作时才考虑LinkedList。
5. JVM类加载过程,双亲委派机制
类加载过程:
类加载分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析统称为连接。
加载阶段通过类的全限定名获取二进制字节流,将字节流转化为方法区的数据结构,在堆中生成Class对象。
验证阶段确保字节码的安全性,包括文件格式验证、元数据验证、字节码验证、符号引用验证。
准备阶段为类变量分配内存并设置初始值。注意这里是初始值不是代码中赋的值,比如int类型是0,引用类型是null。但如果是final常量,准备阶段就会赋值为代码中的值。
解析阶段将符号引用替换为直接引用,也就是把类名、方法名这些符号转换为实际的内存地址。
初始化阶段执行类构造器方法,这时才会执行静态变量赋值和静态代码块。触发初始化的时机包括new对象、访问静态变量或方法、反射调用、初始化子类时先初始化父类、main方法所在类等。
双亲委派机制:
Java有四层类加载器:启动类加载器、扩展类加载器、应用类加载器、自定义类加载器。它们之间是父子关系,不是继承关系。
双亲委派的工作流程是:收到类加载请求时,先检查类是否已加载。如果没加载,不会自己加载,而是委派给父加载器。父加载器也会继续向上委派,直到启动类加载器。如果父加载器无法加载,才由子加载器自己加载。
比如加载String类,会一直委派到启动类加载器,由它加载rt.jar中的String。
双亲委派的好处:
一是避免类重复加载,同一个类只会被加载一次。二是保护核心类库,即使自己写一个java.lang.String也无法替换系统的String,因为启动类加载器已经加载了官方的Stri
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
Java面试圣经,带你练透java圣经
查看12道真题和解析