1. hashCode和equals
hashCode介绍
hashCode()的作用是获取哈希码,也称为散列码,它实际上是返回一个int整数,这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode()定义在JDK的Object.java中,Java中的任何类都包含了hashCode()函数。
散列表存储的是键值对(key-value),特点是:能根据key快速检索出对应的value,这其中就利用到了散列码(可以快速找到需要的对象)。
为什么要有hashCode,以“HashSet 如何检查重复”为例:
对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,看该位置是否有值,如果没有,HashSet 会假设对象没有重复出现。但是如果发现该索引位置已有元素,这时会调用 equals() 方法来检查两个对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会发生哈希冲突,将新对象挂载到链表或红黑树上。这样就大大减少了 equals 的调用次数,相应就大大提高了执行速度。
- 如果两个对象相等,则hashCode一定相同
- 两个对象相等,对这两个对象分别调用equals方法都返回true
- 两个对象有相同的hashCode值,他们也不一定是相等的
- 因此,equals方法被重写过,则hashCode方法也必须被重写
- hashCode()的默认行为是对堆上的对象生成哈希值。如果没有重写hashCode(),即便两个对象成员变量完全一致,该类的两个对象哈希码大概率不同,equals默认比较地址也不会判定相等
2. ConcurrentHashMap原理,jdk7和jdk8版本的区别
JDK7
底层数据结构
ReentrantLock + Segment + HashEntry
外层是Segment数组,单个Segment内部封装HashEntry数组,数组每个下标位置挂载单向HashEntry链表。
寻址查询
采用两次哈希定位:
- 第一次哈希锁定目标Segment;
- 第二次哈希在该Segment内定位链表头节点。
加锁机制
Segment直接继承ReentrantLock实现分段锁;
仅锁定当前操作的Segment分段,其余Segment完全不受锁影响;
并发上限等于Segment分段数量,可在构造方法手动指定;
单个Segment内部数组扩容,不会干扰其他分段。
get读操作
查询全程不需要加锁,依靠volatile修饰变量保证数据可见性。
JDK8
底层数据结构
synchronized + CAS + Node + 链表+红黑树
废弃Segment分层结构,底层直接是Node数组;
链表长度超过阈值会转为红黑树优化查询效率;
Node节点里val值与next指针均用volatile修饰,保障多线程可见。
基础修改操作
查找、替换、新增赋值优先使用CAS无锁操作。
锁策略
不再锁整个分段,仅对当前哈希桶链表头head节点加synchronized锁;
锁粒度大幅缩小,不会阻塞其他桶位元素读写,并发性能更高;
扩容支持多线程并发协助迁移数据,不会阻塞全局读写。
读操作无锁设计
Node的val、next被volatile修饰,读写之间数据互相可见;
哈希数组table本身由volatile修饰,扩容时读线程可以及时感知数组变化。
对比总结
- JDK7依靠Segment分段锁+ReentrantLock,锁定整个分段,两次hash寻址,各分段独立扩容;
- JDK8移除Segment,改用CAS+对桶头加synchronized锁,链表过长转为红黑树,支持多线程并发扩容,锁粒度更细、并发能力更强;
- 两个版本get操作均不加锁,依靠volatile关键字保证读取数据可见性。
3. 如何实现一个IOC容器
简要回答
- 配置文件配置包扫描路径
- 递归包扫描获取.class文件
- 利用反射机制,筛选需要交给IOC容器管理的类
- 对需要注入依赖的属性完成依赖注入
详细回答
- 在配置文件中指定需要扫描的包路径
- 自定义注解,分别标记控制层、业务层、持久层、依赖注入、读取配置等标识注解
- 读取配置文件中的扫描包路径,遍历路径下文件与文件夹,将所有后缀为.class的文件存入Set集合去重存储
- 遍历Set集合,通过反射获取类对象,筛选带有指定组件注解的类,使用线程安全Map存储实例对象,完成Bean注册
- 遍历IOC容器中所有Bean实例,遍历成员属性,识别注入注解,递归获取对应Bean完成属性依赖注入
4. 什么是字节码?采用字节码的好处是什么?
Java中引入了虚拟机的概念,这台虚拟机在任意平台都为编译程序提供统一接口。
编译程序只需要面向虚拟机,生成虚拟机能够识别的代码,再由JVM解释器将虚拟机代码转换为当前操作系统的机器码执行。在Java中,这种专供JVM解析执行的中间代码叫做字节码(即扩展名为.class的文件),它不面向任何特定CPU处理器,只面向Java虚拟机。
执行流程:.java源代码 → javac编译 →.class字节码文件 → JVM解析翻译为机器码 → CPU执行
好处
- 实现跨平台:源码仅需编译一次生成字节码,不同操作系统只要安装对应平台版本JVM即可运行该字节码文件,实现一次编译,到处运行。
- 兼顾运行效率与跨平台可移植性,弥补传统纯解释型语言逐行解释执行速度慢的缺陷。
5. Java类加载器
Java源码编译后生成class字节码文件存储在磁盘,类加载器ClassLoader是JVM中专负责查找、读取class字节码文件,将类加载至JVM方法区,并在堆中生成对应Class对象,程序后续依靠该Class对象创建实例、访问类成员。
JVM内置三种类加载器,自上而下双亲委派层级关系如下:
启动类加载器(Bootstrap ClassLoader)
最顶层加载器,由C++编写,本身并非Java类;负责加载JDK核心基础类库,例如java.lang包下Object、String等核心类,加载路径为jre/lib下核心rt.jar等包。扩展类加载器(Extension ClassLoader)
父加载器为启动类加载器,用于加载jre/lib/ext扩展目录下的jar包,用来拓展JDK原生基础功能。应用程序/系统类加载器(Application ClassLoader)
开发默认使用的加载器,父加载器是扩展类加载器;加载项目classpath下自定义编写代码,以及Maven/Gradle引入的第三方依赖Jar包。
6. 双亲委派模型
加载流程
- 应用类加载器收到类加载请求,不会自身直接加载,优先委托父加载器(扩展类加载器)尝试加载;
- 扩展类加载器继续向上委托给顶层启动类加载器;
- 启动类加载器在自身加载路径范围内查找目标类,查找成功则直接加载;若查找失败,请求逐层向下回退,由下级加载器在自身路径中完成类加载。
作用
- 安全防护:防止开发者自定义
java.lang.String这类核心类覆盖JDK原生基础类,避免恶意代码篡改底层API,保障程序运行安全。 - 避免重复加载:同一个类只会被某一个加载器加载一次,防止JVM方法区中存在多份完全相同的Class对象,节约内存空间。
7. Java中的异常体系
Java中所有异常与错误的顶层父类为Throwable。
Throwable下分为两大子类:Exception与Error。
- Error:属于程序无法手动处理的严重系统级错误,一旦触发通常直接终止程序进程。
- Exception:不会直接导致程序整体崩溃,又划分为运行时异常
RuntimeException与受检异常CheckedException。- RuntimeException运行时异常:程序运行期间抛出,编译阶段不会强制校验捕获,会终止当前线程执行。
- CheckedException受检异常:编译期强制校验,必须使用try-catch捕获或者throws向上抛出,否则代码编译报错。
8. GC如何判断对象可以被回收
1. 引用计数法
给每个对象维护一个引用计数器,新增引用计数+1,引用失效释放计数-1,计数器为0时标记可回收。
缺陷:若A对象持有B引用、B同时持有A引用,二者外部无其他引用,计数器仍为1,对象永远无法被GC回收,出现内存泄漏,因此Java未采用该算法。
2. 可达性分析法(Java正式采用)
以GC Roots作为起始节点向下遍历搜索,遍历经过的链路称为引用链。若一个对象没有任何引用链与GC Roots相连,则判定该对象不可达,标记为可回收对象。
可作为GC Roots的对象:
- 虚拟机栈(栈帧本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中Native方法JNI引用的对象
对象回收补充说明
被标记为不可达不等于立即销毁:对象拥有一次自救机会,依靠finalize()方法。
- 对象第一次被标记不可达时,放入F-Queue队列;
- 由低优先级Finalizer守护线程执行对象finalize()方法;
- 若在finalize方法内重新将该对象赋值给GC Roots可达引用,对象自救成功,跳过本次GC回收;
- 若自救失败,第二次标记后该对象会被直接回收。
9. 线程的生命周期/线程有哪些状态
线程共五种核心状态:新建、就绪、运行、阻塞、终止。
阻塞状态细分三类:
阻塞分类
- 等待阻塞:运行线程执行wait()方法,释放持有的所有锁资源,线程移入等待池。无法自动唤醒,必须由其他线程调用notify()或notifyAll()唤醒;wait()是Object类方法。
- 同步阻塞:线程尝试获取synchronized同步锁时,锁已被其他线程占用,线程进入锁池阻塞排队竞争锁。
- 其他阻塞:线程执行sleep()、join()方法,或是发起IO请求时进入阻塞。sleep超时、join等待线程结束、IO操作完成后,线程重新转为就绪状态;sleep()是Thread类静态方法。
各状态详解
- 新建状态(New):仅new创建Thread线程对象,未调用start(),未开启线程调度。
- 就绪状态(Runnable):调用start()后线程进入就绪线程池,具备运行资格,等待操作系统分配CPU时间片。
- 运行状态(Running):就绪线程获取CPU时间片,执行run()方法内业务代码。
- 阻塞状态(Blocked):线程主动/被动放弃CPU使用权,暂停执行,解除阻塞后只能先回到就绪队列。
- 终止状态(Dead):run()方法正常执行完毕,或运行过程抛出未捕获异常终止线程,线程生命周期结束。
10. sleep()、wait()、join()、yield()的区别
锁池与等待池概念
- 锁池:所有竞争同一把synchronized同步锁的线程存入锁池;锁被占用时其余线程在锁池等待;锁释放后锁池内线程争抢锁,抢到锁后进入就绪队列等待CPU调度。
- 等待池:调用wait()后线程进入等待池,等待池内线程不会参与锁竞争;必须被notify/notifyAll唤醒后,线程才会转入锁池参与锁争夺。notify随机选取一条等待池线程移入锁池;notifyAll将等待池全部线程移入锁池。
方法区别
- 归属类:sleep是Thread类静态本地方法;wait是Object类本地方法。
- 锁释放:sleep不会释放持有的同步锁;wait会释放当前对象锁并进入等待队列。
- 同步依赖:sleep无需配合synchronized同步代码块;wait必须写在synchronized修饰的代码内。
- 唤醒机制:sleep休眠时间结束自动解除阻塞;无参wait()必须被其他线程唤醒,带超时时间wait()超时后自动唤醒。
- 使用场景:sleep多用于线程定时休眠、轮询间隔暂停;wait用于多线程之间生产者消费者等线程间通信场景。
- CPU调度:sleep让出CPU时间片强制上下文切换;wait被唤醒后需要重新竞争锁,不一定立刻获取CPU执行权。
yield()
执行yield()后线程直接回到就绪状态,主动让出本次CPU执行权,但依旧保留CPU调度资格,操作系统下一次调度仍有可能再次选中该线程执行。
join()
在主线程B中调用线程A.join(),线程B进入阻塞状态,直到线程A执行完毕结束,或是调用interrupt中断等待,B才会解除阻塞继续执行。