Java 基础知识、JVM、Java多线程、Java 网络编程
JVM 、 Java 基础知识、多线程和网络编程校招面经整理、热身
Java 基础知识、JVM、Java多线程、Java 网络编程相关
Java SE 基础
String、StringBuffer、StringBuilder的区别:
- String 用 final 修饰了 char[],是不可变类,可以视为常量,若要修改的话需要生成一个新的 String 对象。
- StringBuffer 和 StringBuilder 的 char[] 是可变的,可以进行修改。
- 其中 StringBuffer 的方法加了同步锁,线程安全。
- 而 StringBuilder 的方法没有加同步锁,非线程安全,但性能较好。
HashMap 底层原理、添加元素的底层实现:
- 1.8 之前,数组 + 链表,key 的 hashCode 经过若干次扰动后得到哈希值,求余(hash & (size() - 1))之后放入数组,冲突则使用链地址法,添加元素采用头插法。
扩容后需要重新计算各个 key 的 hash。 - 1.8 之后,扰动算法改进为一次扰动, hashCode 高 16 位与低 16 位 异或后获得哈希值。
数据结构采用 数组 + 链表/红黑树,采用尾插法。
扩容:当元素个数超过 容量 * 负载因子(初始值分别为 16,0.75,负载因子是考虑了泊松分布之后在减少哈希碰撞次数以及节约空间方面取了折衷值),或者链表长度超过 8 且表中元素个数小于 64,会执行 resize,容量加倍,每个 key 的 (hash & newCapacity) 结果最高位作为判断依据,等于 0 则保留原位,否则移到 原位置 + oldCapacity 的位置。
链表转换为红黑树:当链表长度超过 8,且表中元素个数超过 64,会将链表转换为红黑树。
面向对象编程的特性:
- 封装:隐藏类的实例细节,只提供规定的方法供外部访问数据
- 继承:子类继承父类的字段和方法,(private 字段可以继承,无法直接访问,可以通过父类提供的方法访问),实现代码复用。方法重写、重载
- 多态:
- 引用多态,父类引用既可以指向本类对象也可指向子类对象
- 方法多态,父类引用指向不同子对象时,调用的方法不同。
网络编程
BIO、NIO、AIO 的区别,各自适用场景
BIO 同步阻塞IO
步骤
- 服务端创建了一个 ServerSocket
- 客户端创建一个 Socket 向服务端发送连接请求
- 服务端 ServerSocket 收到请求后,创建一个 Socket 线程与客户端通信。
阻塞是针对连接状态而言的,客户端发送请求后需要在连接中等待服务端响应直到返回。
每一个新的客户端接入,服务端都要创建一个线程进行请求服务,大量请求会伴随大量线程的创建,服务端负载会增大。
优化:采用 Acceptor 模型,服务端的通信统一到 Accepter 转发,后台用一个个 Server 线程对应客户端处理。客户端视角则只是与 Accepter 交互。
适用于连接数小且固定,对延迟敏感的场景,对服务器资源要求高。
NIO 同步非阻塞IO
基于 Reactor 模型来实现。
- 每个客户端与服务端建立一个 Channel 缓冲区通信,Channel 也会被注册到 Selecter 线程。
- 服务端用一个 Selector 线程轮询所有 Channel。
- 每一次轮询完毕,对所有有事件的 Channel,将请求加入请求队列,服务端生成一个个线程去处理一个个请求。
非阻塞是针对连接状态而言的,客户端(和服务端)不需要阻塞于某一个连接中,而是等待 请求/响应 事件的驱动,当有事件发生时,线程才会读取 channel。对于线程状态而言,NIO 的调用仍然是阻塞的。
适用于连接数多且短,对延迟不敏感的场景,比如聊天、弹幕服务器,服务器间通讯。
AIO
异步,发起调用后,线程不再等待返回结果,而是继续执行,IO 操作由操作系统完成。
当操作系统完成 IO 后,通过客户端给定的回调接口,通知客户端 IO 完成的消息。
适用于连接数多且长的场景,比如相册、云盘等。
JVM
JVM 进程与线程
11. JVM 常用工具:
- jps:显示当前进程
- jstack:生成 JVM 当前时刻的线程快照。
- 先用 top 查看最消耗 cpu 的进程,然后用 top -Hp pid 查询进程下所有线程的运行情况
- 再用 jstack pid 查看线程快照
- jmap:打印指定进程的共享对象内存映像或堆内存细节,生成堆转储快照
- 可以 jmap -heap pid 查看堆使用情况
- 可以 jmap -histo pid 查看堆中数量及大小
- 可以 jmap -dump,然后 jhat -port portid,将堆转储快照详情输入到指定端口
- jinfo:进程配置信息
- jstat:显示虚拟机运行状态信息
- jstat -option(如 常用 gc,可以查看 gc 的运行情况) vmid interval count
- javap:反编译代码,或查看字节码
JVM 内存结构
JVM 内存划分、运行时分区、线程私有和共享区域:
- 堆(线程共享):主要存放对象实例(包括类对象,就是类加载的产品,静态变量就存在类对象中)以及常量池。垃圾回收器 GC 也主要针对这个区域工作。
- 方法区(jdk 1.8 以后实现方式改变,改为直接内存中的元空间,常量池从方法区移到了堆内存中):主要存放类信息(类的元数据 meta,包括方法名等)
- 虚拟机栈(线程私有):存放函数调用所需的数据,由一个个栈帧组成,主要包括方法索引、局部变量表、输入输出参数等。调用函数对应着栈帧压入,函数返回(return 或 抛出异常)对应着栈帧弹出。
- 本地方法栈(线程私有):类似虚拟机栈,只不过执行的是 Native 方法。
- 程序计数器(线程私有):选取当前执行的字节码指令
另外还有一部分直接内存,属于非运行时数据区,也是线程共享的。
垃圾回收 GC
怎么理解 垃圾回收 GC:
这个问题很抽象,我第一次被问到时候一脸懵逼,不知道从何下手,垃圾收集器涉及 JVM 内存管理的方方面面,具体某一个问题可能还有得说,如果要给一个抽象笼统的描述反而太难了。回头翻阅《深入理解Java虚拟机》,想要从里面寻章摘句拼凑出一个「标准答案」出来。
如果现在再让我回答这个问题,我会从以下角度切入,然后由面试官展开某一方面的交流:
「垃圾收集」是为了更好地支持动态内存分配,在这个过程中不外乎要解决 3 个问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
根据目前的垃圾收集理论,我们可以可以笼统地回答以上三个问题,分别是:
- 哪些内存? —— 使用可达性分析算法,标记 GC Roots 关联到的对象,清除其余内存。再具体点,标记方法可以分为「增量更新」和「原始快照」;根据对象与垃圾回收的互动又可以将对象引用分为「强引用」、「软引用」、「弱引用」、「虚引用」。
- 什么时候? —— 垃圾收集的策略,何时触发 Minor GC、Major GC、Full GC、Mixed GC?
- 如何回收? —— 依赖于不同垃圾收集器的具体实现。简单来说,主要涉及到 3 种算法 「标记-清除」、「标记-复制」、「标记-整理」,以及各种收集器之间的实现细节。
对于自己熟悉的部分,可以略微展开详细说说,引导面试官提问。因为这几个部分深挖下去都是无底洞,很难雨露均沾的。面试官也只会挑自己熟悉的问。
可达性分析,强引用、软引用、弱引用、虚引用:
可达性分析主要用于判断对象是否可以存活。
- 强引用:由 GC Roots 直接或间接引用的对象,这类对象不会被垃圾回收器回收。其中 GC Roots 包括如下对象
- System Class:由 bootstrap / system class loader 类加载器加载的类对象。
- JNI Local / GLobal:本地方法中所使用的局部、全局变量。
- Java Local:代码中的局部变量。
- Thread (Block):正在运行的线程(块)。
- Busy Monitor:调用了 wait() 或 notify(),或者被加了 synchronized 的对象,也就是被加锁了的对象。
- Java / Natvie Stack Frame:本地方法栈、线程执行栈。
- Finalizable:在等待队列中等待执行 finalize() 方法的对象。
- Unfinalized:尚未执行 finalize() 并且不在 Finalizable 的等待队列中的对象。
- Unreachable:标记为 root 的对象。
- Unknown:标记为未知类型 root 的对象。
- 软引用:软引用可有可无,在内存足够时不会被回收,但不足时可能会被清理。
- 弱引用:标记为下一次清理的对象,垃圾回收器在执行部分回收时会清理之,但是垃圾回收器不一定每次都能扫描到它们,因此可以存活一段时间。
- 虚引用:用于跟踪对象与垃圾回收器的互动,其他时候相当于没有引用。
内存分配与回收策略:
内存分配
- 绝大多数时候,新对象优先在 Eden 区中分配,若此时发现空间不足,发起一次 Minor GC
- 大对象(数组、字符串等需要较大连续内存的对象)直接在老年代中分配
- 一般情况下,对象创建后分配到 Eden区,在经过一次新生代 GC 后进入 FS 或者 TS,同时对象有年龄记数,年龄记数到达阈值的对象进入老年区。
- 动态老年判断:两个 Survivor 区中如果存在一个年龄值,该年龄值的对象所占空间之和超过 Survivor 区的一半,则这个年龄作为动态阈值,超过的对象直接进入老年区。
- 逃逸分析当中,有些优化措施会导致对象不分配在堆中:
- 将不会逃逸的对象直接分配到栈上
- 用标量替换将对象分解为基本成员变量分配到栈中
- 空间分配担保(Minor GC 发生前,老年代需要有足够区域来担保新生代中全部(部分)对象,否则要进行 Full GC)
内存回收
- Eden 区满了会触发 Minor GC,Eden 和 From Survivor 清空,存活的对象进入 To Survivor 区,超龄对象直接进入老年区,然后倒置 From 和 To 区。
- 老年代满了会触发 Major GC。
- Full GC 清理整个新生代和老年代,触发条件:
- 执行 System.gc
- Eden 区执行 Minor GC 时,Survivor 已满、老年代也满
- gc 线程的存活对象无法放入预留空间
- 新生代的晋升平均大小大于老年代剩余空间
经典垃圾收集器:
这些太杂了,个人认为问的可能性不大,大概了解一下几种收集器的工作区域和性能特点,重点掌握 CMS、G1,以及他们之间的主要区别就行。
Serial 串行 新生代 复制算法 响应速度优先 单 CPU 环境 Client 模式,简单易用效率高
Serial Old 串行 老年代 标记-整理 响应速度优先 单 CPU 环境 Client 模式、CMS 的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多 CPU 环境 Server 模式、常与 CMS 配合,相当于多线程版的 Serial
Parrallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或者 B/S 系统服务端上的 Java 应用
G1 并发 新、老 标记-整理+复制 响应速度优先 面向服务端应用,将来替换 CMS
G1收集器解决了什么问题?
在G1收集器出现之前,CMS是常用的专注响应性的老年代收集器,CMS在大内存情况下gc pause时间长 (remark 阶段,CMS 为了确认是否有跨代引用对象会对整个堆进行扫描),G1收集器的出现可以解决了应用在使用大内存的情况下gc pause时间长的问题,并且提供可预期的gc停顿时间。G1使用什么方式解决问题?
G1收集器将内存化整为零,将堆内存分割成一个个大小相同的region,年轻代(eden区、survivor区)和老年代(old区)由多个物理上不连续的region组成,G1根据模型计算出回收收益最高,且开销时间不超过用户指定GC停顿时间的若干region,通过复制算法gc这些region。而在CMS中会回收整个老年代。
GC 常见优化案例
服务器反应慢等情况
- 如果不知道是哪个进程导致,可以先用 top 查看进程运行情况,确定引起资源占用的进程 pid。然后 top -Hp pid 查询该进程下的线程执行情况, jstack pid 查看线程快照,确认是否是 gc 线程引起。
- 若是 gc 线程引起,可以用 jstat -gc vmid 查看其 实时执行情况,排查出问题的 gc 过程,针对性调优。
- 通常 GC 策略需要在「GC 频率」 和 「停顿时间」 两者之间达成平衡,所以常见的性能问题也往往是由于这两者之间出现了失衡,总结如下:
- 频率过高
- Full GC 频繁:Full GC 频率往往与老年代不稳定(即频繁有对象存活进入老年代)有关,考虑是否程序本身设计了许多大对象,大对象直接分配进入老年代,如果堆内存(或者老年代内存)分配不足,就会导致 Full GC 频繁。考虑增大堆内存,或者调整新生代老年代的内存大小比例。
- Minor GC 频繁:新生代空间太小,使用 -Xmn 参数调整。
- Major GC 频繁:(类似 Full GC)
- 停顿过长
- Full GC 停顿过长:与频率问题对应,堆内存如果较大,程序每次进行 Full GC 时 STW 间隔就会较长。可以考虑将一台服务器拆分为多个逻辑集群,前端用反向代理进行负载均衡,这样多个逻辑节点之间的 Full GC 可以错峰进行,整体上的停顿会减轻。
- Minor GC 停顿过长:(不常见)
- Major GC 停顿过长:常见于老年代收集器,如 CMS,CMS remark 阶段会搜索整个堆确保没有跨代引用,因此 Major GC 之前会执行 Minor GC 进行预清理,由 CMSMaxAbortablePrecleanTime 参数管理其时间限制,若超过时间,会强制中止 Minor GC 进入 remark,而过多的新生代对象会导致 remark 搜索时间变长。可以用 CMSScavengeBeforeRemark 参数确保这个 Minor GC 完成,来降低 remark 的工作量。
- 频率过高
类加载相关
一个类由一个类(Class)文件定义,类加载就是将这个文件载入内存,经过一系列步骤转化为可以被虚拟机使用的 Java 类。
类加载有哪些步骤
- 加载:通过类的全限定名获取定义此类的二进制字节流,根据这个字节流代表的静态存储结构,在方法区构造一个运行时数据结构,作为方法区这个类相关数据的访问入口。
- 验证:验证 Class 文件的字节流符合《Java 虚拟机规范》定义的约束,包括文件格式验证、元数据验证、字节码验证、符号引用验证。验证环节不是必须的,如果整个程序的代码经过了多次使用而没有出问题,可以考虑 -Xverify:none 关闭类验证措施,缩短类加载时间。
- 准备:为类变量(即 static 变量)赋 0 值(如果还是 final 修饰的变量,可以直接赋定义值)。
- 解析:将常量池的符号引用替换为直接引用。分为类解析(传给当前类的类加载器)、字段解析(查找 class_index 中的索引)。两种解析方式都会按继承关系「自下而上」地递归查找各个类(接口)及其父类(父接口),直到找到匹配的目标类或字段。「解析」在某些情况下可以在「初始化」之后开始。
- 初始化:执行类类构造器 <\clinit>() 方法(注意区别于我们自行编写的构造方法 Constructer 对应的实例构造器 <\init>() 方法),该方法会为类中所有的类变量(static 变量)赋值,以及执行 static 修饰的代码块。
类加载器、双亲委派模型:
- Java 的类都是由某个类加载器完成类加载步骤的,我们比较某两个类是否「相等」(如 类对象的 equals() 方法、isInstance() 方法、isAssignableFrom() 方法),先要比较这两个类是否由同一个类加载器加载。
- 双亲委派模型是为了保证这种一致性而被提出的,其逻辑由 loadClass() 方法实现,具体来说,类加载器收到类加载请求后,会先尝试将请求委派给父类加载器完成。这样子可以保证同一个类文件,一定是由同一个类加载器加载的。按照优先级的层次关系,双亲委派模型从上到下可以分为:
- 启动类加载器 BootStrap Class Loader,负责加载
\lib 目录的类库。 - 扩展类加载器 Extension Class Loader,负责加载
\lib\ext 目录的类库。 - 应用程序类加载器 Application Class Loader,负责加载用户路径 ClassPath 上的类库。此为默认类加载器,除非用户自定义了类加载器。
- 自定义类加载器 User Class Loader
- 启动类加载器 BootStrap Class Loader,负责加载
- 双亲委派模型破坏(即不遵守双亲委派模型的情况):双亲委派模型只是一种推荐的开发规范,不具有强制性。有些时候为了克服一些问题我们就不需要严格遵守了。
- 对于 JDK 1.2 以前的远古代码,用户可以重写 protected findClass() 方法,当父类加载失败时,类可以按用户的意愿进行加载。
- 如果基础类型需要回调用户的代码,可以用「线程上下文类加载器」Thread Context ClassLoader 自定义加载。
- 为了实现程序热部署、热替换,我们需要让每一个程序模块 Bundle 拥有自己的类加载器,这样替换时可以将整段代码连同类加载器替换掉,不需要关机重启。
并发编程(多线程)
同步和异步
- 同步:调用发出以后,直到得到结果以后才能返回。
- 异步:调用发出以后,直接返回,无需等待。
JMM Java 内存模型
线程生命周期和状态
- New 初始:new 了一个线程后还没调用 start() 时的状态。
- Runnable 运行态:线程调用 start() 后进入就绪队列(Ready 就绪态);或者在就绪队列中被分配到 CPU 时间片(Running 运行中)。这两种状态都笼统地称为运行态。
- Blocked 阻塞:线程竞争锁失败而等待锁释放的状态。
- Waiting 等待:调用 wait() 方法进入该状态,需要等待其他线程做出一些动作如 notify() 回到 runnable 状态,否则无限期等待。
- Time_ Waiting 超时等待:调用 sleep() 进入该状态,在计时器结束后自行返回 Runnable 状态。
- Terminated 终止:线程执行 run() 方法完毕,没有任何方法使得 terminated 状态的线程「复活」。
什么是(什么时候发生)线程上下文切换
笼统地讲,线程释放占用的 CPU 时间片,即切换上下文,具体有:
- 主动让出,如执行 sleep(),wait() 等
- 时间片用完,自然让出
- 调用了阻塞类的系统中断,比如 I/O 中断
- 线程被终止或结束运行
sleep() 和 wait() 的区别
sleep | 共有 | wait |
---|---|---|
都可以暂停线程的执行 | ||
不释放锁 | 释放锁 | |
用于暂停 | 用于线程交互、通信 | |
线程会自动苏醒 | 线程不会自动苏醒,需要调用 notifyAll() 或者同一个对象上的 notify(),或者使用 wait(long timeout) 等待超时 timeout 时间后自动苏醒 | |
Thread 类的静态本地方法 | Object 实例的本地方法 |
Thread 类实例可以不调用 start() 而直接调用 run() 方法吗?
可以,但是作用不是启动线程,而是将 Thread 视作一个普通的对象(而非一个线程),按序执行 run() 方法中的内容。
如果要多线程工作,则必须调用 start() 方法,使线程完成准备工作后进入 runnable 状态,自动执行 run() 方法。
实现一个线程安全的 HashMap:
参考 ConcurrentHashMap 和 Hashtable 的实现
类 Hashtable 实现
给所有 HashMap 的增、删、改、查方法加 synchronized 全表锁
类 ConcurrentHashMap 实现
jdk 1.7 中加分段锁,将数组分割为若干个 segment,对 segment 加分段锁
因为哈希表不同的桶之间不会出现线程冲突的情形
jdk 1.8 中,可以只锁一个数组元素的头结点
Synchronized 锁膨胀(锁膨胀)机制相关(阿里高频题):
之前一直拖着没看《Java 并发编程的艺术》,被阿里面试官问了这个问题一脸懵逼,其实就在开头的几个章节,栽过跟头才知道疼,赶紧翻起来结合网上一些博客快速消化一下。
原子类 Atomic:
该类具有原子、原子操作特征
- 基本类型: AtomicInteger、AtomicLong、AtomicBoolean
- 引用类型: AtomicReference、AtomicStampedReference(带有版本号控制,如 CAS 解决 ABA 问题就能用到)、AtomicMarkableReference
- 数组类型: AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
- 对象的属性修改类型器类型: AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
Java 多线程实现方式:
- 写一个类,继承 Thread 类,重写 run 方法
- 实现一个 Runnable 接口,重写 run 方法,以之作为参数调用一个 new Thread 构造函数
- Callable 和 FutureTask 创建线程,需要重写 call 方法,此方可以有返回值,在 FutureTask 对象中可以用 get 获取此返回值
- 线程池创建,可以用 Executors 类框架创建 ExecutorService 、Callable、ScheduledExecutorService 等来创建线程
线程池
线程池使用 Executors 或者 ThreadPoolExecutor 类创建
线程池 ThreadPoolExecutor类:
线程池 ThreadPoolExecutor类:
七大参数
- int corePoolSzie 核心线程池大小
- int maximumPoolSize 最大线程数
- long keepAliveTime 核心线程空闲后超过这个时间,关闭线程池
- TimeUnit unit 存活时间单位
- BlockingQueue
workQueue 阻塞队列,当工作的线程数超过 corePoolSzie,此时新加入的线程、以及调用了一些阻塞函数后,当前的线程 这些线程要放入阻塞队列 - TreadFactory threadFactory 线程工厂:用于创建线程的类
- RejectedExecutionHandler handler 拒绝策略(详见1.5.2):阻塞队列满后对新加线程的处理方法
4种拒绝策略
- 中断抛出异常
- 默默丢弃任务,不进行任何通知
- 丢弃在阻塞队列中存在时间最久的任务
- 通知提交任务的线程,自行执行任务