ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java基础八股文核心考点全解析:JVM、集合与并发原理一次讲透

Java基础八股文核心考点全解析:JVM、集合与并发原理一次讲透 做Java这行特别是准备校招、社招跳槽的兄弟一定绕不开“基础八股文”这道坎。你说的这个“java基础八”其实就是大家常说的基础核心知识点的第八套体系梳理对应到实际面试里就是那些反复出现、看似简单却极容易翻车的题目JVM内存模型、类加载机制、集合原理、并发编程、异常反射、泛型擦除……这些东西平时写业务代码不一定天天用但一到面试就是试金石能直接测出你到底是十几年经验一直在写CRUD还是真的研究过底层原理。我见过不少人背了三百道题简历写得花团锦簇结果面试官一句“HashMap在JDK 7和JDK 8下有什么区别为什么用红黑树”当场卡住。也有相反的情况工作三五年被问到“synchronized的锁升级过程”只知道锁粗化、锁消除这几个名词但具体怎么从偏向锁升级为轻量级锁细节完全说不清楚。说实话这些知识点不是光靠背就能过关的得弄清楚底层逻辑知道它在JVM里到底是怎么跑的才能真正答到点上。这篇文章我没打算写成那种一本正经的教科书就把“java基础八”这套高频核心考点拆开揉碎把每个知识点的原理、常见坑、面试回答思路一次讲透。不管你是在校学生准备实习还是工作几年想系统复习一遍按这套思路过完基础这块基本能站得住脚。1. 先弄清楚为什么基础题总是绕不开1.1 基础扎实和背熟八股文完全是两码事很多初学者有个误区觉得“背八股文”就是死记硬背没什么技术含量。但实际你去面试就会发现面试官不是为了听你背结论而是想通过这些问题判断你有没有系统思考过技术方案。举个例子面试官问“ArrayList和LinkedList的区别”如果只回答“一个是数组一个是链表一个查询快一个增删快”那基本只能得及格分。深入一层他其实想知道你知不知道二者扩容机制的本质差异ArrayList底层Object[]数组是如何扩容和删除的LinkedList的Node节点结构用了双向链表它在内存里是不是一定比ArrayList节省空间再进一步如果你知道LinkedList实际占用的内存通常比ArrayList更大因为每个节点都要存前驱和后继指针在64位JVM上指针压缩开启后每个Node也要占不少字节那面试官会觉得你确实研究过而不是背了两个结论。所以说基础扎实的人是在理解底层原理的基础上建立起来的记忆背八股文的人只是记住了表面的答案。同一个问题区别就在于你有没有往深处想过一层。1.2 这套“基础八”涵盖哪些范围我结合岗位要求和面试高频题把“java基础八”整理成六大块JVM内存与类加载、集合框架、并发编程、异常与反射、泛型、面向对象与设计原则。这六块是Java基础里最常考的也是项目里出现问题后最容易被忽略的底层源头。每块我都会讲清楚三个维度原理是什么、常问的面试题是什么、实际项目中会踩什么坑。这样你看完之后不光是能应付面试更重要的是真能加深对Java这门语言的理解。2. JVM内存与类加载最常被问懵的知识点2.1 运行时数据区一张表理清楚JVM内存模型是基础里的基础也是没有实际运行经验的初学者最容易混淆的地方。先看一张我整理的内存区域表内存区域存储内容是否线程私有异常类型程序计数器当前线程执行的字节码行号指示器是无Java虚拟机栈局部变量表、操作数栈、动态链接、方法出口是StackOverflowError / OutOfMemoryError本地方法栈native方法调用时分配的栈是StackOverflowError / OutOfMemoryErrorJava堆对象实例、数组GC管理的主要区域否OutOfMemoryError方法区/元空间类信息、常量、静态变量、JIT编译后的代码否OutOfMemoryError注意几个容易踩的细节。第一程序计数器是唯一不会出现OOM的区域。原因很简单JVM规范明确规定它占用内存极小且生命周期与线程一致线程结束后就被回收不会出现内存不足的异常。第二虚拟机栈和本地方法栈的区别。虚拟机栈服务Java方法调用本地方法栈服务native方法调用。但HotSpot虚拟机实际实现里这两块栈是合二为一的所以遇到StackOverflowError时不用纠结到底是哪段代码本质是线程请求的栈深度大于JVM允许的最大深度。第三JDK 8之后方法区演进为元空间Metaspace最重要的变化是不再使用堆内存改用本地内存。默认情况下元空间是无限的受系统物理内存限制这个改动很大程度避免了原来永久代频繁出现OOM的问题。面试时如果能把这个演进过程说出来会是不小的加分项。2.2 类加载机制与双亲委派别只讲结论类加载机制是JVM面试题的常客几乎每家面试官都会问。核心流程是加载、验证、准备、解析、初始化这五个阶段。加载阶段通过类的全限定名获取该类的二进制字节流将字节流代表的静态存储结构转化为方法区的运行时数据结构并在堆中生成一个Class对象作为访问入口。验证阶段是安全性检查主要防止恶意字节码危害JVM包括文件格式验证、元数据验证、字节码验证等这一步在最严格的安全模式下还会开启校验器。准备阶段是为类变量分配内存并设置初始零值注意这里用的是“零值”不是代码里写的初始值。比如定义static int a 100准备阶段a的内存值是0真正赋值100发生在initialization阶段也就是编译器自动生成的clinit()方法里。这个细节经常被问到尤其是区分实例变量和类变量时。解析阶段将符号引用替换为直接引用。初始化阶段才真正执行类构造器。双亲委派模型是必须画图记住的流程。一个类加载器收到加载请求后不会自己先尝试加载而是把请求委派给父类加载器每一层都是如此直到传至BootstrapClassLoader。只有父类加载器反馈无法完成加载时子类才尝试自己加载。为什么要这么做核心原因是保证Java核心类库的类型安全。举个例子如果程序员自定义了一个java.lang.String类自定义类加载器不经过双亲委派直接加载了它那JVM里就会出现两个String类核心库的String反而被覆盖整个类型系统就乱了。双亲委派机制确保所有类的加载请求最终都会到达Bootstrap核心类由Bootstrap加载自定义的同名类根本无法加载。但双亲委派并不是完美的面试里还可能追问如果想打破双亲委派怎么办标准答案有两个方式。一是重写loadClass方法不向父类委派二是使用线程上下文类加载器这是JDBC等SPI机制的做法因为JNDI、JDBC这些由启动类加载器加载的代码需要调用由应用类加载器加载的实现类双亲委派做不到只能靠父类加载器反向请求子类加载器完成加载。2.3 对象创建过程与内存分配一个Java对象从new指令开始到对象在堆上可用大致经历这几步类加载检查、分配内存、初始化零值、设置对象头、执行init方法。分配内存时如果堆内存规整用指针碰撞如果内存不规整用空闲列表。这里要考虑多线程并发分配的安全问题解决方式有两种一种是对分配内存的动作加锁另一种是TLABThread Local Allocation Buffer即每个线程在堆中预分配一块内存线程先在TLAB里分配对象TLAB用完后再加锁申请新的缓冲区。HotSpot默认开启TLAB这算是个比较细节的考点答出来面试官会觉得你有深度。对象头也不是一个简单概念Mark Word存储对象哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等。64位JVM下Mark Word占8字节加上Class Pointer 4字节指针压缩后一个普通对象头至少12字节算上对齐填充就是16字节。网上说“60亿个对象大约需要十几GB内存”并不夸张大量小对象比数组更费内存就是因为每个对象都有对象头开销。3. 集合框架从源码角度重新理解3.1 ArrayList和LinkedList再深一层的理解前面提过基础答案这里展开讲源码层面。ArrayList底层是Object[]数组默认容量是10但在JDK 8里通过无参构造创建的ArrayList初始数组其实是一个空的Object[]只有第一次add时才调用ensureCapacityInternal方法将容量扩到10。add元素时会计算容量如果不够调用grow方法扩容新容量是旧容量的1.5倍即oldCapacity (oldCapacity 1)。比如容量是10时扩容到1515扩容到22不是整倍数这是个容易被忽略的点。还有扩容要调用Arrays.copyOfSystem.arraycopy是native方法性能不错但在超大数组下频繁扩容会有明显的拷贝开销。而LinkedList的Node节点除了存储数据item还有prev和next两个引用。64位JVM开启指针压缩的情况下一个Integer对象占16字节对象头12字节4字节数据Node节点自身需要24字节左右12字节对象头4字节item引用4字节prev4字节next加上对齐所以存一万个整数LinkedList占用的内存明显大于ArrayList。实际项目中LinkedList的频繁增删效率也不一定比ArrayList高因为ArrayList的批量拷贝是native级别的实现非常快LinkedList反而需要new Node对象、维护前后指针对象分配开销更大。所以我的经验是大部分业务场景无脑用ArrayList就行。LinkedList只有在使用Deque接口功能比如需要频繁头尾插入删除时才值得考虑用ArrayDeque其实更合适。3.2 HashMap原理与扩容机制HashMap算是Java基础面试题的天花板级别考点。先说JDK 7和JDK 8的关键差异JDK 7使用数组加链表头插法JDK 8使用数组加链表加红黑树尾插法。当链表长度达到8且数组长度达到64时链表转红黑树当红黑树节点数降到6时退化为链表。为什么要转红黑树因为链表查找元素的时间复杂度是O(n)当哈希冲突严重、链表非常长时查询性能会退化。红黑树是自平衡二叉查找树查询时间复杂度为O(log n)能保证极端情况下查找效率不会太差。但红黑树节点TreeNode大约是普通Node的两倍大所以不能动不动就转树需要达到8这个阈值才转。关于树化阈值为什么是8源码注释里提到遵循泊松分布。在随机哈希码下链表长度达到8的概率大约千万分之六这个概率已经非常低。但如果重写了hashCode且设计极差导致大量元素落到同一个桶里也存在长度超过8的可能。结合数组长度必须达到64的条件从概率和内存占用两方面做了平衡。扩容机制方面HashMap默认负载因子0.75。当size超过容量乘以负载因子时触发rehash容量翻倍为原来的2的n次方。指数扩容有个关键优势旧数组中的元素在迁移时要么在原来的下标位置要么在原位置加旧数组长度的位置。因为新数组容量是旧数组的两倍key在原数组的hash值如果多了一位且这位是1就会移动到“原位置旧容量”如果这位是0就留在原位置。还有一个容易答错的问题HashMap为什么容量总是2的n次方因为取模运算 hash (n - 1) 要求n为2的幂这样能高效计算桶下标同时让元素分布更均匀减少冲突。如果初始化时传了非2的n次方容量HashMap会用tableSizeFor方法通过位运算算出不小于指定值的2的n次方数。3.3 ConcurrentHashMap面试常问的点ConcurrentHashMap在JDK 7时代采用Segment分段锁数据结构是Segment数组加HashEntry每个Segment继承ReentrantLock多线程访问不同Segment互不干扰。JDK 8放弃了分段锁改用CAS加synchronized锁粒度从Segment降为桶级。JDK 8的具体操作逻辑put时先判断key是否为空如果数组为空先调用initTable初始化如果目标桶为空利用CAS写入这也是无锁操作如果桶非空则用synchronized锁住链表的头节点或树的根节点再执行插入。在插入过程中如果发现链表的长度达到8且数组长度小于64时会先执行扩容而不是把链表树化。扩容过程也比较复杂JDK 8引入了ForwardingNode来标记正在扩容的桶。多线程可以协助迁移数据迁移完成后在原桶位置放置ForwardingNode其hash值为MOVED-1。其他线程put时发现ForwardingNode会帮助进行扩容避免阻塞等待。这种“多线程协作扩容”的设计面试时讲明白会非常有冲击力。4. 并发基础线程安全的底层逻辑4.1 synchronized的锁升级过程synchronized是Java并发编程最基础的同步机制JDK 6之后的优化方向是引入锁升级机制让锁从无锁状态逐步升级。锁状态依次是无锁、偏向锁、轻量级锁、重量级锁只能升级不能降级。偏向锁的核心思想是大多数锁不仅不存在多线程竞争而且总是由同一线程多次获得。当一个线程访问同步块时会在对象头和栈帧的锁记录中存储锁偏向的线程ID后续该线程再进入同步块时直接CAS判断对象头中的偏向锁指针是否指向当前线程如果是就不用再进行任何同步操作。如果出现另一个线程竞争偏向锁首先尝试让原持有偏向锁的线程在安全点暂停然后检查原线程是否已退出同步块如果已退出将对象头设置为无锁状态重新偏向新线程如果还在执行则升级为轻量级锁。轻量级锁通过CAS尝试将对象头的Mark Word替换为指向锁记录的指针如果替换成功表示获取锁成功失败则说明已有其他线程竞争膨胀为重量级锁。重量级锁依赖于操作系统的互斥量线程从用户态切换到内核态开销非常大这也是为什么在实际并发场景里synchronized一开始被诟病性能差的原因。理解了这套升级机制再遇到什么场景适合用synchronized、什么场景适合ReentrantLock都能自己判断了。4.2 volatile关键字的可见性与有序性volatile在Java并发里是个高频考点核心作用有两个保证变量修改的可见性禁止指令重排序但不保证原子性。可见性指的是一个线程修改了volatile变量后其他线程能够立即看到这个修改。原理是Java内存模型规定写volatile变量时JMM会把该线程对应的工作内存中的共享变量值刷新到主内存读volatile变量时会强制从主内存中重新读取。在X86处理器上volatile的写操作底层会加一个lock前缀指令这个指令会让其他CPU核心缓存行失效从而保证在多核CPU上的可见性。禁止指令重排序则更关键。经典的DCL单例模式里为什么单例对象要加volatile因为创建对象的过程不是原子的在编译器和CPU层面可能重排序。正常步骤是分配内存、初始化对象、将引用指向内存但重排序后可能变成分配内存、将引用指向内存、初始化对象。如果另一个线程在引用地址非空以后、对象完成初始化之前直接拿走使用就会拿到一个半初始化状态的对象。加了volatile通过内存屏障阻止了这种重排序能让单例对象安全发布。很多人会把volatile和synchronized混在一起其实使用场景非常清晰只有一个写线程或者多个线程只读变量需要立即被其他线程看到用volatile如果有多个线程同时写共享变量且需要复合操作的原子性则必须使用synchronized或锁。4.3 CAS与AQS常见疑问CAS全称Compare And Swap是很多并发工具类的底层实现。它通过比较当前内存值和预期值是否相等相等则更新不相等则失败重试。硬件层面x86的cmpxchg指令就是这个操作的实现。CAS的经典问题有三个。ABA问题因为CAS只比较值的当前值假设一个变量原来是A线程1将它改为B线程2又将B改为A此时线程1再CAS发现值还是A就认为没有被修改过而实际上已经被改动了两次。解决办法是使用AtomicStampedReference给它加一个版本号每次修改版本号加一比较时同时比较值和版本号。自旋开销问题CAS失败后通常会自旋重试在高竞争情况下会一直空转消耗CPU。解决方案包括限制自旋次数、使用synchronized升级为重量级锁等。只能保证单个变量的原子操作问题如果要保证多个变量的原子性需要把多个变量封装成对象用AtomicReference来操作。AQSAbstractQueuedSynchronizer则是Java并发包的核心框架ReentrantLock、Semaphore、CountDownLatch、ThreadPoolExecutor的底层实现都依赖于它。AQS维护了一个volatile的state变量和一个FIFO双向等待队列。拿ReentrantLock举例线程尝试获取锁时会通过CAS把state从0改为1成功则获得锁失败则进入等待队列并用LockSupport.park阻塞自己。释放锁时将state从1改回0并唤醒队列里的下一个线程。可重入特性的关键在于同一个线程再次获取锁时state值加一释放时减一减到0才真正释放锁给其他线程。5. 异常机制、反射与泛型容易被问倒的细节5.1 异常体系与finally的真实行为Java异常体系分两大支Error和Exception两者都继承自Throwable。Error是程序无法处理的严重错误比如OutOfMemoryError、StackOverflowError出现这种错误不该尝试捕获Exception分为RuntimeException运行时异常和CheckedException编译时异常RuntimeException包括NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException等编译时异常则必须显式捕获或抛出。面试常会考一个细节finally块中如果有return会覆盖try或catch块中的return。比如try里return 1finally里return 2方法最终返回的就是2。原因在于编译后的字节码中finally块的代码会被插入到所有可能的出口之前如果finally里出现return会直接跳转并覆盖之前的返回值。这也是之前网上传过的一个面试坑题。还有一个经常被忽略的点如果try块里执行了System.exit(0)finally不会执行。因为exit会直接终止当前运行的JVM实例。此外如果finally块抛异常会覆盖原始异常。处理这种情况的推荐写法是用try-with-resources它可以在try执行完后自动调用close方法并且在多个资源关闭异常时原始异常不会被覆盖而是作为主异常抛出附带被抑制的异常。5.2 反射机制应用在哪些真实场景反射是Java动态特性的重要体现运行时动态获取类的完整信息并操作。Class对象可以通过三种方式获得类名.class、对象.getClass()、Class.forName()。最常见的应用场景有几个。Spring框架里依赖注入和注解处理依赖反射。容器启动时扫描Bean类通过反射调用构造器创建实例再根据Autowired或Resource注解通过反射把依赖的实例注入进去。JDBC驱动加载也离不开反射。Class.forName(com.mysql.cj.jdbc.Driver)DriverManager会通过反射调用Driver类的静态代码块完成驱动注册。动态代理JDK动态代理的核心也是反射通过java.lang.reflect.Proxy.newProxyInstance方法动态生成一个实现指定接口的代理类方法被调用时统一转发到InvocationHandler的invoke方法中。反射的性能开销显著大于直接调用所以频繁调用且性能敏感的场景要避免使用反射。现在很多框架通过MethodHandle或者缓存Method对象的方式做优化但在业务代码中能不用反射就不用。5.3 泛型擦除你以为的类型安全其实靠强转Java泛型是伪泛型运行时泛型信息会被擦除。ArrayListString和ArrayListInteger在JVM里是同一个ArrayList类类型参数在编译阶段被擦除为Object或者边界类型。这也是为什么运行时无法通过反射获得泛型类型它根本不存在。泛型擦除有个常见的坑ListString的add方法在编译期做了类型检查但通过反射add一个Integer对象运行时会成功。因为编译后方法签名是add(Object)。不过泛型擦除也不是把所有信息都抹掉。有一个特例通过TypeVariable和ParameterizedType可以在运行时获取泛型签名。这也是Gson、Jackson这类库能从TypeToken里拿到泛型类型的原理。比如你写Type type new TypeTokenListString() {}.getType()这里包含了泛型信息是因为匿名内部类在字节码层面保留了泛型签名常量Signature属性反射可以用getGenericSuperclass读取它。Gson解析复杂泛型时就依赖这个机制来保留ListString的实际类型。6. 面向对象与设计原则别只会背概念6.1 封装、继承、多态的真实意义三个基础概念问法却千变万化。你要是只回答“封装是隐藏内部细节继承是子类复用父类多态是同一接口不同实现”基本就是入门水平。封装说白了就是让你把可变的部分藏在内部对外只暴露稳定的接口。这样以后修改内部逻辑调用方完全感知不到。我见过很多项目里到处是public字段的DTO改一个字段名要全局搜索这就是封装没做好。继承是个容易被过度使用的特性。继承的优点是代码复用缺点是父子类高度耦合父类任何代码变化都会影响所有子类。Effective Java里明确建议优先使用组合而不是继承。组合就是在一个类里持有一个其他类的引用通过委托的方式复用功能。比如一个缓存接口已有RedisCache实现想加一层日志不需要继承RedisCache而是写一个LoggingCache里面持有一个Cache方法调用前打日志再委托给真正实现灵活性强得多也不怕父类方法签名变化。多态的价值在于可替换性。面向接口编程方法的入参定义成接口类型而不是具体类调用方可以传任意实现类。扩展时新增一个实现类就行不用动已有代码。理解这些之后设计模式里的策略模式、工厂模式本质都是多态的具体运用。6.2 设计原则在面试中怎么答才加分设计原则的核心是SOLID。S单一职责原则一个类只负责一个职责比如一个工具类里不要既做JSON解析又做数据库读写又做文件操作。O开闭原则对扩展开放对修改关闭业务功能增加时优先扩展而非修改已有类。L里氏替换原则子类可以替换父类且行为不发生变化如果重写父类方法改变了语义逻辑就不符合这句原则。I接口隔离原则接口要小而专不要一个万能接口包含各种不相干方法。D依赖倒置原则上层模块不依赖底层模块两者都依赖抽象比如业务层依赖仓库接口和实现接口而不是依赖具体的数据库实现。面试答这些原则时不要只背名词。最好配合一个自己项目中的案例比如之前做的一个订单状态流转模块最开始用if-else写了一大堆分支后来按策略模式重构每种状态策略一个类通过Map注册新增状态时只需要新增一个策略类并注册即可这就体现了开闭原则。有真实案例支撑和光背概念给人的印象完全不同。7. 把这些知识点串起来的实操心得7.1 怎么把基础知识和项目经验结合起来回答面试官问HashMap原理之后大概率会接一个场景题如果有几十个线程同时往Map里写数据你会怎么处理这时候你要能顺理成章地把HashMap、ConcurrentHashMap、Collections.synchronizedMap串起来回答说明为什么线上用到Map并发场景不直接加synchronized而是用ConcurrentHashMap。回答时可以说如果直接用HashMapJDK 7环境下并发put可能导致链表成环CPU飙升到100%JDK 8下虽然改进了但仍然可能丢失数据用Collections.synchronizedMap虽然安全但是内部加的是全局锁并发度很低而ConcurrentHashMap使用了CAS和synchronized的桶锁并发度更高读操作几乎无锁所以实际生产用ConcurrentHashMap。这一套话术下来面试官就知道你不只背了概念还了解它们的来龙去脉。7.2 常见误区与避坑速查表易错认知准确理解是赋值是地址比较基本类型比较值引用类型比较引用地址重写equals后还需重写hashCode字符串用拼接效率低JVM在字符串拼接时用StringBuilder进行了优化循环体内拼接性能问题更大100是intnew Integer(100)可以都用比较如果值在-128~127之间Integer缓存生效用比较结果为true超出范围则可能为false应该用equals异常捕获顺序无所谓编译器强制子类异常必须在父类异常之前捕获否则子类catch永远不可达try-finally可以保证任何情况下都会执行关闭逻辑线程中断、System.exit、底层IO错误等场景可能跳过finally执行泛型类型运行时就是ArrayList底层是ArrayList还有一个非常隐蔽的坑重写equals方法时必须同时重写hashCode。原因在于HashMap、HashSet这类基于哈希的容器先通过hashCode定位存储桶再通过equals判断是否相等。如果两个对象equals相等但hashCode不同它们会存放在不同位置导致HashMap里存入了两个逻辑上相等的对象数据重复甚至污染。这是线上一个经典bug的来源值得反复提醒。最后再分享一个我个人的习惯复习这些基础知识点时不要只盯着结论试着想着在哪些业务场景会遇到它。比如在写接口时如果有一段代码是从Map里根据对象的某个字段取数据你可以停下来想想字段作为keyhashCode和equals有没有实现正确如果有人传了null值ConcurrentHashMap能不能处理多一次这样的思考基础就会真正内化成你的能力。
返回列表