《深入计算机组成原理》对Java开发的启发

浮点数的表示和运算


1. 浮点数的表示

浮点数以float为例,单精度的 32 个比特可以分成三部分:
在这里插入图片描述

  • 第一部分是一个符号位,用来表示是正数还是负数。我们一般用 s 来表示。在浮点数里,我们不像正数分符号数还是无符号数,所有的浮点数都是有符号的。
  • 接下来是一个 8 个比特组成的指数位。我们一般用 e 来表示。8 个比特能够表示的整数空间,就是 0~255。我们在这里用 1~254 映射到 -126~127 这 254 个有正有负的数上。因为我们的浮点数,不仅仅想要表示很大的数,还希望能够表示很小的数,所以指数位也会有负数。
  • 最后,是一个 23 个比特组成的有效数位

综合科学计数法,我们的浮点数就可以表示成下面这样:

在这里插入图片描述

因为这种机制,浮点数在定义时就可能会丢失一定的精度,比如 float f = 0.9 , 但使用科学计数法得到的值却是 0.8999999999999999


2. 浮点数的运算

浮点数的运算遵循 先对齐、再计算 的原则。 两个浮点数的指数位可能是不一样的,所以我们要把两个的指数位,变成一样的,然后只去计算有效位的加法就好了。

在进行指数对齐的过程中,需要对较小的数的有效位进行右移,每右移一位指数位增加为原来的两倍。在右移的过程中,较小的数会丢失精度,当右移的位数超过23次时,那么较小数的有效数为就为0了,也就是当两个浮点数之间相差超过2^24倍,也就是1600 万倍时,较小数会被忽略,那这两个数相加之后,结果完全不会变化。


CPU多级流水线,指令预读,乱序执行

一颗CPU内有多级流水线,甚至有多条流水线,一条流水线有N级组成,每一级在同一时间能运行一条指令的一个阶段,所以一颗CPU在同一时间能运行多条指令。

当多条指令之间没有依赖关系时,那么CPU就能并行执行这多条指令,提高程序运行速度。也就是多条没有关联的指令间执行是没有顺序的,但最终输出结果前会对结果进行重排序。在外界看来指令执行是按顺序的,但实际内部是多条指令并行执行的,哪条指令的依赖来得早哪条就能先执行,但输出结果前还是要根据指令顺序对结果排序,也就是输出程序运行结果是要按照指令顺序来的,输出顺序靠后的指令结果前还是要等待靠前的指令执行完。


## CPU多核缓存一致性机制 CPU在操作内存数据时,会将内存中的数据拷贝到CPU的高速缓存(L1 L2)中,在某些时间点间将数据从缓存同步回主内存。每个CPU都有自己的高速缓存,这就导致同一个数据再多个缓存中存在一致性问题,当某个数据 X 在CPU A 内被修改时,但其他CPU不知道 X 被修改了,缓存中的还是旧的数据,这就可能导致程序运行出错。如果要避免这种情况而在修改完某个数据时立即同步回主内存,一方面会导致程序运行效率降低,毕竟CPU高速缓存的读写效率比内存的读写效率高出至少几十上百。而且不加锁的并发操作总归是不安全的,加锁也会加剧性能问题。

为了解决CPU高速缓存一致性问题,CPU内部实现了了总线嗅探机制和针对Cache Line 的MESI协议


1.总线嗅探

所谓总线嗅探,就是每个CPU对每个Cache Line的加载,修改,写回 都会发布相应的事件到CPU总线上,每个CPU会向总线注册多种事件监听器,监听自己感兴趣的事件,然后做相应处理,典型的处理就是将Cache Line 的状态在 MESI 4种状态间流转。


2.MESI 协议

MESI 协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:

  • M:代表已修改(Modified)
  • E:代表独占(Exclusive)
  • S:代表共享(Shared)
  • I:代表已失效(Invalidated)

每个内存数据(其所在的Cache Line) 在CPU缓存中都处于上述4种状态之一,通过上述4中状态的轮转,来实现以下准则:

  1. 写传播(Write Propagation)。写传播是说,在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里
  2. 事务的串行化(Transaction Serialization),事务串行化是说,我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的

通过上述机制,解决了缓存一致性问题: CPU读取数据时都能保证读取到最新的数据,多个CPU操作同一个数据时,保证是串行的,且外界看到的数据变化顺序保证和CPU操作的顺序一致


Volatile关键字的作用


1.修饰复杂类型属性

计算CPU能保证缓存一致性,那么Java里还需要Volatile这个关键字吗,或者说这个关键字附加了CPU缓存一致性之外的什么功能 ?

对一个变量赋值的操作时一个原子操作时,有没有Volatile修饰是没有什么区别的,因为不同线程对同一个内存做操作会遵循MESI协议,保证缓存一致性。但当变量的赋值操作不是一个原子性操作时,那么就有区别了,比如以下代码:

private User user
void init(){
	user = new User();  // 此行编译后会有3条指令
}

以上代码执行时实际分三步,也就是实际编译过后会生成3条指令:

  1. 为User对象分配内存空间
  2. 执行User对象的构造函数,初始化对象
  3. 将user变量指向User对象所在的内存地址

因为CPU能同时执行多条指令,所以会有一个指令并行执行的过程。因为指令2 ,3 依赖指令 1 的分配内存,所以指令1执行结束后才能执行指令2,3。但 2, 3 指令之间是没有依赖关系的,所以指令 2 ,3 可能会同时执行,但不确定那条先执行完成。

假设在多线程下,执行顺序为1 > 3 > 2, 且在执行完指令3时CPU分片时间到了,当前线程停顿,CPU切换到了其他线程。其他线程获取到了成员变量user , 此时 user 已经赋值了,但是没有初始化,也及时其他线程拿到的是一个没有初始化过的 User 对象,是一个半成品,这会给程序造成隐患。

使用Volatile修饰成员变量,能够在 user = new User();前后添加内存屏障,禁止内部的3条指令进行重排序,确保user对象被其他线程获取到时是初始化过的。


2.修饰简单类型属性

当Volatile 修饰简单类型的成员变量是,代码如下:

private int a;
private int b;

void init(){
	a = 1;
	b = 1;
	notifyOtherThread();  // 唤醒其他线程
	Thread.sleep(10);
	a ++;
}

void compare(){
	// 当前线程阻塞,可被 init() 方法唤醒
	if(a == b){
		........
	}
}

init () 方法中,指令经过重排序后可能导致 a++ 指令在 notifyOtherThread() 之前就运行,那么此时 a = 2, 这样在 compare() 方法内 a 和 b 就不相等了,而我们希望的逻辑是相等的。因为a 和 b 的依赖关系是在另一个线程里,所以不能阻止 a ++ 的重排序。而通过Volatile修饰 a b,这样能够禁止 a b 之前的指令排序到 a b 行之后执行,同时也能禁止 a b 之后的指令排序到 a b 行 之前执行。

上述代码中就能禁止 notifyOtherThread() 指令排序到 a ++ 之后执行,这样就能保证业务逻辑的正确性。


CPU分支预测机制


1.机制说明

程序编译过后的指令是有序的,但是实际执行时就不一定是按照指令顺序来的,比如程序有 if-else 判断,while for i do while 循环,方法调用等等。

java 堆里有个程序计数器,对应CPU里是PC寄存器,存放的是当前CPU下一条待执行的指令的地址。程序在当前指令运行结束时会将下一条指令的地址放入程序计数器,不断的更新程序计数器里的指令地址这样程序就能正常的运行下去。

上文说了,CPU有指令预读的功能,也就是当前指令拿到了,然后就可以马上去拿下一条指令了,甚至一次性拿取多条指令,可以让多条指令近似并行的执行。那如果当前指令后是一个多分支的指令集合,从中选择一条指令运行,那如何做出选择?

如果等上条指令执行完再从程序计数器里选择的话那就不能发挥CPU的指令并行的功能了,如果随机取一个分支的指令或者默认取第一个分支的指令,那么可能实际运行的结果和取得指令不一致,那么就需要撤回已取的指令,然后重新加载正确的指令,这也会多出额外的开销,甚至比等待上条指令结果后再取的情形更差。

指令并行还是需要并行的,但是不能盲目的或者静态的取某个分支,需要根据指令的历史运行结果来做一个预判。CPU引入了一个双模态预测器,也就是记录上两次的分支的运行结果,当连续两次都选择指定分支时,那么下次运行时就会预加载制定分支的指令。在大量的分支判断情况下,这种预测机制能够有极高的准确率,往往达到90%以上。

比如下列代码:


public class BranchPrediction {

    public static void main(String args[]) {       
        for (int i = 0; i < 100; i++) {
            .......
        }
    }
}

循环变量 i < 100 在前面99次循环都是正确的,也就是正确率高达99%。


2.实战案列

public class BranchPrediction {

	// int数组,内含100个数字,分别是 0-99, 不确定是否有序
	private int[] ints = new int[]{.....};

    public static void main(String args[]) {       
        for (int i = 0; i < 100; i++) {
            if(ints [i] > 50){
            	.......
            }else{
            	......
            }
        }
    }
}

上述代码对 ints 数组遍历,是排序过的遍历性能好,还是没排序过遍历性能好呢 ? 了解了CPU 分支预测机制后,确定排序过的性能好


public class BranchPrediction {
    public static void main(String args[]) {        
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            for (int j = 0; j <1000; j ++) {
                for (int k = 0; k < 10000; k++) {
                }
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start));
                
        start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            for (int j = 0; j <1000; j ++) {
                for (int k = 0; k < 100; k++) {
                }
            }
        }
        end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end - start) + "ms");
    }
}

上述两种多层循环的写法,哪个性能好?循环的分支预测只有在最后一次不满足条件时才会发生指令预读错误的情况,上述两种循环的预测错误情况如下图:
在这里插入图片描述

所以外层循环越小的写法,预测错误的次数越少,所以性能越高


CPU Cache Line 机制,内存边界对齐,Java对象要求8字节的整数倍


1.CPU Cache Line

CPU在得到一个内存地址后,从主内存加载数据,加载多大的数据,内存地址上是不会记录数据长度的,如果一次加载太多的话,可能需要的数据很小而造成性能浪费;而且缓存数据使用LRU策略来管理,可能会导致淘汰掉大量刚刚使用过的缓存信息,造成接下来的运算缓存命中率降低;如果加载太少比如几个字节的话,可能每次需要的数据都要多次加载,性能低下,所以每次加载多大的数据是一个很重要的问题。

CPU定了一个缓存单位大小,Cache Line Size,默认64字节,也就是通过一个内存地址每次加载到CPU缓存里的数据是64个字节,根据缓存的空间局部性原理,当需要一份数据时,有很大可能同时需要此数据的后几份数据,所以CPU Cache Line 机制能在很大程度上提高程序的运行性能,比如有序遍历数组,有序集合;或者需要的数据很小,在64个字节以内,那么很可能一次加载就能缓存需要的所有数据。

缓存机制看起来很美好,但还有不少问题要解决,比如 缓存数据是刚好以内存地址开始的64个字节的数据吗?如果是的话,那多个Cache Line有交错时要怎么处理,比如是否要重复加载(Cache Line 能交错则100个字节的内存地址最多能在37个Line中),一个Cache Line 修改了是否要让交错的Cache Line失效等等一些列问题。这些都会极大的降低CPU的性能,所以CPU规定了Cache Line是不能有交错的,也就是每个内存地址都在唯一的Cache Line中

CPU将内存以4K为大小来分页,每个Cache Line 大小64字节,所以一页刚好是64个缓存行,内存地址通过页表能很快确定对应的Cache Line是否在CPU缓存中。接下来还有一个问题,如果CPU从内存取数据是按规定单位取得,但是如果存数据的时候有没有按这个规定单位存放,比如分配一个16字节的数据,如果前8个字节分配在一个Cache Line, 另外8个字节分配在后一个Cache Line上,那你怎么样都要取两次。内存分配数据时是否要考虑按Cache Line Size 大小,这个问题在下面的说明。


2.内存边界对齐

假设有一段128个字节的内存,CPU第一次分配60个字节在0-59的位置上得到一个内存地址A,表示的是内存地址0,然后再分配8个字节在60-67上生成内存地址B,表示的是内存地址60。当我需要加载内存地址B表示的数据时,第一次加载 0-63 地址的数据,发现不够,还需要再加载 64- 127 的数据,也就是加载8个字节的数据需要两次,Cache Line机制没有很好的发挥作用。内存空间没有浪费,但是加载性能上有所降低,这是时间换空间的典型。

假设有一段128个字节的内存,CPU第一次分配16个字节在0-15的位置上得到一个内存地址A,表示的是内存地址0,然后再分配8个字节在64-67上生成内存地址B,表示的是内存地址60。当我需要加载内存地址B表示的数据时,第一次加载 64-127 地址的数据,也就是一次就能完美的加载小于64字节的数据,能极大的提高效率,这就是典型的空间换时间。但是这样的话会极大的浪费内存空间,每次分配的内存空间都是64字节的整数倍,如果应用每次需要的内存空间很小时,会有非常多的内存碎片,很不可取。

那有没有一些折中的策略,既能兼顾内存使用率,也能发挥Cache Line的作用呢?有的,只需要定一个合适的分配内存的最小单位,类似于Line Size这样。CPU规定了每次分配的内存空间都要是8字节的整数倍,比如应用申请12个字节,那么会分配16个字节的内存空间,下次分配时从第17字节开始。因为 8 字节刚好是基本数据类型的大小上限,这样分配内存时能兼顾所有基本数据类型而且浪费的空间比较少;64字节又是8字节的整数倍,所以一次分配的N(N<=8)个8字节的内存地址是有可能在一个Cache Line 上的。现在常见的内存分配器都默认使用这种策略,每次分配的内存地址是8字节的整数倍,比如 jemalloc ,这就是内存边界对齐的概念。


3.Java里的内存边界对齐

Java里面也默认延续了内存边界对齐的思想:对象的大小必须是8字节的整数倍,这样一方面能支持CPU Cache Line机制,另一方面能够压缩内存地址的大小,本来在超过4G的内存空间上表示一个内存地址需要8个字节,而Java在小于32G的堆里可以用4个字节表示任意一个对象地址,详见:浅析JVM内存指针压缩


CPU缓存的空间局部性 对数组,List的影响


在上文中我们分享了CPU Cache Line机制,那我们在遍历数组,有序集合时是否能应用到这个机制来提高遍历性能。


1.数组

数组内的元素在内存中是按顺序分布的,如果数据类型是基本数据类型,那么存放的就是基本类型的数据;如果是复杂类型,那么存放的是指向对象的指针,每个指针默认4字节。

当数据类型是基本类型时,性能最好;当数据类型时复杂类型时,每次最多能加载16个对象指针,实际使用时还需要额外根据指针的内存地址加载实际的对象,会有额外的一步损耗。


2.List

有序集合的典型,ArrayList,内部使用数组存储元素,但是List对象的类型不支持简单数据类型,如果是简单数据类型需要对齐做装箱,比如 : List<Integer>,

List<Integer\> ints = new ArrayList();
ints.add(1);

上述ints 对象添加了一个元素1,但是实际内部会对 1 做自动装箱 : ints.add(new Integer(1));,也就是说集合对象不能直接存储基本类型数据,存储的是对象的指针。这样在遍历时性能会查 int[] 至少一个量级。 而且自动装箱就会多出额外的数据,比如一个 int 占用4字节,而一个Integer占用 16个字节,而且还有指针4字节,用有序集合比数组每个元素至少多16字节。所以在操作有序的基本类型数据集时,如果要压缩占用内存,使用数组是最优的选择


Java对象的内存分布,Unsafe,缓存填充


1.Java对象的内存分布

假设有如下一个Class:

public class User{
	private int id;
	private long telephone;
	private Integer studentId;
}

上述User类型的实例化对象在内存中是如何分布的?我们用一张图来说明


在这里插入图片描述

一个Java对象在实例化之前就能确定对象大小,根据Object Head,属性类型 ,属性个数,就能计算出来字节数,最后不满8字节的整数倍需要加上对齐填充。所以在分配对象内存空间时,直接分配一块连续的内存空间即可,不会存在空间增大和缩小的情况。


2.Unsafe

可以通过Unsafe来验证对象在内存中的分布情况。比如我想确定属性 telephone 在对象中的排列位置:

	private static Unsafe unsafe;

    static {
        try {
            Field getUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            getUnsafe.setAccessible(true);
            unsafe = (Unsafe)getUnsafe.get(null);
        } catch (Exception ex) { throw new Error(ex); }
    }

    @Test
    @SneakyThrows
    public void getTelephoneMemoryOffset() {
        long telephoneOffset = unsafe.objectFieldOffset(User.class.getDeclaredField("telephone"));
        System.out.println(telephoneOffset);  // 16
    }
	

通过Unsafe能够确定对象的属性相对于当前对象的起始地址的偏移量,对象在GC时内存地址会改变,但内部的结构不会改变,属性的内存位置的偏移量也不会改变,所以Unsafe能够直接通过修改内存的形式来修改对象的属性

Useruser = (User)unsafe.allocateInstance(User.class);
unsafe.putLong(user, unsafe.objectFieldOffset(User.class.getDeclaredField("telephone")), 1L);

但是这种修改是不安全的,它绕过了Java的编译校验,所以可能会出现以为指向一头牛,实际指向一匹马的情况,所以Java 把它命名叫Unsafe,不推荐开发时直接使用。


3.缓存填充

我们在上文分享了CPU 的 MESI协议,然后又分享了Java对象的内存分布,因为对象内数据是分布在一起的,当有多线程修改了某个属性后,其他线程的此属性对应的Cache Line失效,每次使用时需要重新加载。那这个过程是否存在性能问题,比如有如下结构的Class:

private final int age
private final String name;
private volatile long count;
private final long time;

有上述4个属性,一个标识了volatile,多个线程会修改此变量值,其他的变量在对象初始化时就固定了,且不会变化。

在多线程情形下,某个线程修改了 count这个变量,有很大几率导致整个对象所在的Cache Line失效,每次访问其他final修饰的变量时也需要重新从主内存中加载,这就有很严重的性能问题,在 count 修改并发越高时越明显。

有没有什么好的解决方法,能够提高其他不会被修改的属性的使用效率 ?我们可以借鉴 高并发框架 Disruptor 的处理方式:

private final int age
private final String name;
private long p1,p2,p3,p4,p5,p6,p7;  // 填充属性
private volatile long count;		// 频繁修改属性
private long q1,q2,q3,q4,q5,q6,q7;  // 填充属性
private final long time;

被频繁修改的属性前后插入额外7个long类型的变量,使得count所在的Cache Line 仅仅包含插入的填充属性,这样想修改count属性时,因为其他有意义的属性和count不在同一个Cache Line上,所以其他的Cache Line能常驻CPU 缓存,提高程序的运行效率,这就是缓存填充机制。


磁盘,内存,CPU缓存的读取延时


存储器硬件介质单位成本(美元/MB)随机访问延时延时时钟周期
L1 CacheSRAM71ns3-4
L2 CacheSRAM74ns12
L3 CacheSRAM15ns30
MemoryDRAM0.01560ns120
DiskSSD(NAND)0.0004150us
DiskHDD0.0000410ms

Java直接内存


1.为什么要有直接内存

1.读取磁盘数据的临时存放空间

当java程序使用到磁盘的数据时,在读取磁盘时默认从指定地址向后顺序读取1M的数到内存,但是很多时候程序在读取时是逐行或者读取到一个Buffer里,读取了数据才算是将数据加载到堆里,那剩下的数据存放在哪里这是个问题。当前进程读取的数据,没有加载到堆里,那么就需要在堆外开辟一块空间用来存放这些读到的数据,这个就是堆外内存,也叫直接内存。

2.写入IO数据的临时存放空间

当Java程序需要将数据通过网络传输到远方,那么需要将数据写入到Socket的缓冲区,在这个过程中CPU会切换到内核空间,有内核态的CPU通过一个字节数组起始地址,写入长度参数将制定内存地址的数据拷贝到Socket缓冲区。

因为此时CPU运行在内核态,而不是Java程序的用户空间,所以CPU只关注的是数据内存地址,而不是对象的内存地址。如果在拷贝数据的过程中,JVM的堆内触发了GC,导致对象的内存地址移动了,也就说数据的内存地址移动了,那么此时CPU是不知道新的数据地址的,此时就会触发拷贝失败的Error。

如果从安全拷贝数据的角度来看的话,最好就是在写入IO数据时不能有GC,但这种设计明显不合理,如果要传输很大的文件,可能耗费时间很长,应用不可能长时间不进行GC,这会导致OOM的Error。所以折中方案就是在堆外开辟一个空间临时存放要写入IO的数据,先将数据拷贝到堆外内存,以为堆外内存不是由JVM掌管,不会有GC,不存在内存地址移动的问题。

3.文件管理空间

很多中间件在持久化数据时需要持续的写入文件,或者随机的读取文件的某一段数据,如果用传统的IO那只能顺序的读取,顺序的写入,功能上不能实现,所以Java针对随机读写情况增加了文件的映射功能。将文件映射到虚拟内存,当读或者想写时如果当前页不存在,触发缺页异常,CPU将页从磁盘加载到内存,然后有操作系统来维护这些页表。如果由堆来管理这些文件映射的空间,那么一反面可能文件非常大,占用空间非常多,导致堆需要频发GC,或者GC时移动数据很吃力,GC时间过长;另一方面当文件的页加载过多时,需要通过淘汰算法刷新部分页,那么需要将脏页写会磁盘,上文说了在写磁盘时数据的内存地址是不能移动的,所以需要堆外空间来存放待刷新数据,所以用堆外内存来管理文件更好点。

2.直接内存使用注意点

直接内存在分配时需要切换到Java内置的C进程,所以内存在分配和释放时耗费比堆内高,而且内存空间在堆外,不归JVM管,所以不会有GC,也就是不会自动释放内存,需要手动释放或者在分配内存时关联上Java内的 sun.misc.Cleaner,当Java对象被回收时,通过其 finalize() 方法触发 Cleaner去释放此对象对应的堆外内存空间。

直接内存分配和释放不容易,所以一般用池化来管理直接内存,而且不需要主动释放,覆盖性的重新分配即可。

当在直接内存上分配空间存放数据时,如果还需要在堆内使用这些数据时,就相当于将数据重新又读取到了堆内,这样就相当于额外多了一步数据拷贝的过程。如果在堆外分配了数据后,直接写入到Socket缓冲池中,那比分配在堆内然后拷贝至直接内存,在拷贝到Socket缓冲区少了一步拷贝,性能要高不少。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值