写了多年代码,你却不知道的程序设计的5个底层逻辑( 五 )

通过上面的代码我们可以通过属性相对对象起始地址的偏移量,来读取和写入属性的值,这也是 Java 反射的原理,这种模式在jdk中很多场景都有用到,例如LockSupport.park中设置阻塞对象 。那么属性的偏移量具体根据什么规则来确定的呢? 下面我们借此机会分析下 Java 对象的内存布局 。
在 Java 虚拟机中,每个 Java 对象都有一个对象头 (object header) ,由标记字段和类型指针构成,标记字段用来存储对象的哈希码, GC 信息, 持有的锁信息,而类型指针指向该对象的类 Class ,在 64 位操作系统中,标记字段占有 64 位,而类型指针也占 64 位,也就是说一个 Java 对象在什么属性都没有的情况下要占有 16 字节的空间,当前 JVM 中默认开启了压缩指针,这样类型指针可以只占 32 位,所以对象头占 12 字节, 压缩指针可以作用于对象头,以及引用类型的字段 。
JVM 为了内存对齐,会对字段进行重排序,这里的对齐主要指 Java 虚拟机堆中的对象的起始地址为 8 的倍数,如果一个对象用不到 8N 个字节,那么剩下的就会被填充,另外子类继承的属性的偏移量和父类一致,以 Long 为例,他只有一个非 static 属性 value ,而尽管对象头只占有 12 字节,而属性 value 的偏移量只能是 16, 其中 4 字节只能浪费掉,所以字段重排就是为了避免内存浪费, 所以我们很难在 Java 字节码被加载之前分析出这个 Java 对象占有的实际空间有多大,我们只能通过递归父类的所有属性来预估对象大小,而真实占用的大小可以通过 Java agent 中的 Instrumentation获取 。
当然内存对齐另外一个原因是为了让字段只出现在同一个 CPU 的缓存行中,如果字段不对齐,就有可能出现一个字段的一部分在缓存行 1 中,而剩下的一半在 缓存行 2 中,这样该字段的读取需要替换两个缓存行,而字段的写入会导致两个缓存行上缓存的其他数据都无效,这样会影响程序性能 。
通过内存对齐可以避免一个字段同时存在两个缓存行里的情况,但还是无法完全规避缓存伪共享的问题,也就是一个缓存行中存了多个变量,而这几个变量在多核 CPU 并行的时候,会导致竞争缓存行的写权限,当其中一个 CPU 写入数据后,这个字段对应的缓存行将失效,导致这个缓存行的其他字段也失效 。

写了多年代码,你却不知道的程序设计的5个底层逻辑

文章插图
 
在 Disruptor 中,通过填充几个无意义的字段,让对象的大小刚好在 64 字节,一个缓存行的大小为64字节,这样这个缓存行就只会给这一个变量使用,从而避免缓存行伪共享,但是在 jdk7 中,由于无效字段被清除导致该方法失效,只能通过继承父类字段来避免填充字段被优化,而 jdk8 提供了注解@Contended 来标示这个变量或对象将独享一个缓存行,使用这个注解必须在 JVM 启动的时候加上 -XX:-RestrictContended 参数,其实也是用空间换取时间 。
jdk6 --- 32 位系统下 public final static class VolatileLong{ public volatile long value = https://www.isolves.com/it/cxkf/bk/2021-03-04/0L; public long p1, p2, p3, p4, p5, p6; // 填充字段 }jdk7 通过继承 public class VolatileLongPadding { public volatile long p1, p2, p3, p4, p5, p6; // 填充字段 } public class VolatileLong extends VolatileLongPadding { public volatile long value = 0L; }jdk8 通过注解 @Contended public class VolatileLong { public volatile long value = 0L; }NPTL和 Java 的线程模型按照教科书的定义,进程是资源管理的最小单位,而线程是 CPU 调度执行的最小单位,线程的出现是为了减少进程的上下文切换(线程的上下文切换比进程小很多),以及更好适配多核心 CPU 环境,例如一个进程下多个线程可以分别在不同的 CPU 上执行,而多线程的支持,既可以放在Linux内核实现,也可以在核外实现,如果放在核外,只需要完成运行栈的切换,调度开销小,但是这种方式无法适应多 CPU 环境,底层的进程还是运行在一个 CPU 上,另外由于对用户编程要求高,所以目前主流的操作系统都是在内核支持线程,而在Linux中,线程是一个轻量级进程,只是优化了线程调度的开销 。
而在 JVM 中的线程和内核线程是一一对应的,线程的调度完全交给了内核,当调用Thread.run 的时候,就会通过系统调用 fork() 创建一个内核线程,这个方法会在用户态和内核态之间进行切换,性能没有在用户态实现线程高,当然由于直接使用内核线程,所以能够创建的最大线程数也受内核控制 。目前 Linux上 的线程模型为 NPTL ( Native POSIX Thread Library),他使用一对一模式,兼容 POSIX 标准,没有使用管理线程,可以更好地在多核 CPU 上运行 。


推荐阅读