容器
HashMap
存一个 hash 桶,每个桶对应相应 hash 值的链表,从 JDK 1.8 开始,一个链表长度大于等于 8 的时候会将链表转化成红黑树。
ConcurrentHashMap
JDK 1.7 用的是分段锁,几个锁各自对应几个桶区间。到了 1.8 之后,改成了锁单个桶,如果对应桶为空,就 CAS,其他情况使用 synchronized 桶的头节点。
resize 操作 1.7 我不是很清楚,1.8 也是 CAS,通过 CAS 一个叫 sizeCtrl 的 field 实现只有一个线程在 resize。
https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%AE%B9%E5%99%A8.md 上面说 1.7 的 HashMap 和 ConcurrentHashMap 是头插,即把插入元素放到桶的第一个位置,但是自己看1.8的源码都是尾插了。原因是因为扩容的时候是 for 元素拆入,这个时候头插会改变列表的顺序(如果一个几个元素还在一个桶中,它们的顺序会倒过来),如果并发发生,可能会出现链表成环的问题。
WeekHashMap
WeekHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收,可用作缓存。
相关 WeakReference 都被一个 ReferenceQueue 串起来,一些特定的操作会触发清空操作,用来清除 queue 中已被清空的 Entry。
HashTable
方法命名和 HashMap 稍有不同,比如 elements (values()), contains (containsValue)。
HashTable 不支持 null value。
内部 hash 桶相关的数据结构,和 HashMap 相比也是类似的。
两者扩容的方法是不一样的,HashMap 的大小始终是2的幂次,HashTable 则是尽量使用奇数(素数),初始大小为 11,每次扩充为 2n + 1。
HashMap 的优点是计算可直接使用位运算,但是幂次会导致不均匀,所以 HashMap 的 hash 过程中还会加上一个打散操作。
HashTable 是线程安全的,因为使用了 synchronized。
运行时数据区域
- 程序计数器,记录正在执行的 Java 虚拟机字节码指令的地址。
- Java 虚拟机栈,每个 Java 方法在执行时会创建一个栈帧用于存储方法的各种局部信息。
- 本地方法栈,与 Java 虚拟机栈类似,不过是一般用其他语言编写的本地方法服务。
- 堆,所有对象都在这里分配内存,是垃圾回收的主要区域,不同垃圾回收器会用不同的划分方法来切割 Heap(如 CMS 和 G1)。
- 新生代
- 老年代
- 方法区,用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区是一个 JVM 规范,永久代与元空间都是一种实现方式。
- 运行时常量池,方法区的一部分,这些常量在方法运行时会被使用。
- 直接内存,NIO 可以使用 Native 函数库直接分配堆外内存,然后通过堆里的 DirectByteBuffer 对象作为这块内存的引用。
垃圾回收
JVM使用可达性分析算法来判断一个对象是否可以被回收,以GC Roots为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
GC Roots一般包含以下内容:
- 虚拟机栈中局部变量表中引用的对象。
- 本地方法栈中JNI(Java Native Interface)引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
垃圾回收算法
- 标记-清除,检查每个对象是否为活动对象,清除非活动对象并回收空间,由于不会改变活动对象在实际内存中的位置,会产生很多内存碎片。可用的内存放在空闲链表上供搜索。
- 标记-整理,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。不会产生内存碎片,但是速度较慢。
- 复制,将内存划分为大小相等的两块,每次只使用其中一块,当一块内存用完了,就把存活对象搬到另一块上,再把使用过的内存空间做清理。还可以扩展出Eden和Survivor模式,一个Eden,两个Survivor,一个Survivor大小除以其他内存块大小就是最大内存利用率。每次回收时把所有数据迁移到未使用的Survivor上,然后清理其他内存块。
- 分代收集,把内存分为不同块(新生代和老年代),不同块采用不同的收集算法,并在不同时机运行(策略:Minor GC只回收新生代,Full GC回收老年代)。不同的对象的代际主要根据其存活过了几次垃圾回收来决定。
垃圾收集器
- CMS (concurrent mark sweep)
- 流程:
- 初始标记:只标记GC Roots能直接关联到的对象,速度很快,需要停顿。
- 并发标记:进行GC Roots Tracing,此时不需要停顿,用户线程可工作。
- 重新标记:修正并发标记错误,需要停顿。
- 并发清除:不需要停顿。
- 缺点:
- 吞吐量低。
- 需要预留内存,防止浮动垃圾。
- 标记-清除算法留下的内存碎片较多。
- 流程:
- G1,把内存分成了多个大小相等的独立区域,每个小空间可单独进行垃圾回收,这样内存碎片产生的可能变少。每个空间可被计算对应的垃圾回收时间和收获空间,使可预测的停顿时间模型成为可能。
- 流程和 CMS 类似:初始标记、并发标记、最终标记、筛选回收。
- 用到了一个remembered set来标记独立区域之间的对象依赖关系。
并发
线程安全的几种方式
- 互斥同步
- 非阻塞同步 - CAS
- 消灭 / 保护临界区
- 不可变
- final
- String
- enum
- 其他被设计成不可变的类型
- 幂等
- 不可变
- 降低同步 -> 不同步
- ForkJoinPool
- Stream
互斥同步,锁有公平和不公平之分,公平代表多个线程必须按照申请锁的时间顺序来获得锁,不公平则不需要。
下面是Java提供的两种锁机制:
- synchronized,JVM提供。
- 锁升级:虽然各个JVM实现各有不同,但基本可认为synchronized的锁升级是不可逆的,所以不要过于依赖synchronized,各种操作都被JVM实现了,可控制性太差。
- 无锁,该对象未被任何线程占有。
- 偏向锁,该对象已经偏向某一线程(线程通过CAS声明对锁的占有),该线程使用对象不需要任何加锁操作。
- 轻量级锁,使用CAS原语的锁,当一个线程获取偏向锁失败后,锁检测到了多次CAS,于是进入轻量级锁,每次线程调用都需要CAS,多次失败后升级到重量级锁。
- 重量级锁,使用操作系统的锁机制。
- 锁升级:虽然各个JVM实现各有不同,但基本可认为synchronized的锁升级是不可逆的,所以不要过于依赖synchronized,各种操作都被JVM实现了,可控制性太差。
- ReentrantLock,JDK提供。
封装类型
- Condition
- CountDownLatch
- CyclicBarrier
- Semaphore
- BlockingQueue
- etc
中断
和Kotlin的各种中断机制很类似。
InterruptedException
,但一个线程被调用了interrupt()
方法后,如果线程处于阻塞或等待状态,就会抛出InterruptedException
,但是 I/O 阻塞和 synchronized 锁阻塞不会被影响。interrupted()
,线程可以主动调用其内部的interrupted()
方法来知道自己是否已经被中断了。- Executor 的
shutdown()
和shutdownNow()
方法可以关闭线程,前者是等待已运行的线程执行完毕后再关闭,后者则是调用每个线程的interrupt()
方法。
Metaspace
替代了永久代(permanent generation),其作用是:
- 存储所有的 static content,包括
- 静态方法
- primitive variables
- String Pool
- 这就会导致 OOM。
- 存储 classloader
- classloader 用来从 .jar 文件里面导入 .class,每个 class 使用自己的 classloader 可以保证一个 server 对某个 application 代码进行重新部署,而不影响其他 application。
- 这也会导致 OOM。
为什么OOM不会被垃圾回收阻止?貌似是一些实现的问题
- 永久代的 GC 发生在 full gc,频率较低。
- classloader和实例会有循环依赖的问题,貌似在JDK 5及以前不能被很好地解决。
永久代最大的缺点是其大小是不可以动态调节的。