《Java特种兵》阅读笔记(2)

第二章-Java程序员要知道计算机工作原理

Posted by Haiming on March 6, 2020

接着学习这本书。

第2章 Java 程序员要知道计算机工作原理

2.1 Java 程序员需要知道计算机工作原理吗?

这一章主要讲解CPU,内存,磁盘等等角度来看的 计算机的基本原理。也通过缓存来理解优化设计,讲解系统的 I/O 和数据库交互的关系。

2.2 CPU的那些事儿

2.2.1 从CPU联系到 Java

首先我们清楚,每个进程或者是线程发送操作请求之后,最后会由 cpu 来分配时间片来进行处理。

其处理过程是:

先将操作数传递给 cpu,cpu 计算将其写回“本地变量”之中。这个本地变量通常存在于程序所谓的“栈”之中,如果多次对这些本地变量进行操作,那么CPU会将其cache到cpu的缓存之中。CPU 由寄存器,一级缓存,二级缓存,有的还有三级缓存。

一般而言,一级缓存和CPU的延迟在2~3ns 之间,二级缓存通常为 10~15ns,三级缓存在20~30ns,内存在50ns或更高。

在多核的cache 之中,对于某些数据 cache 之后,数据在读取和写入的时候必须满足一些规范,通常叫做“缓存一致性协议”,就像在分布式系统之中我们也要保证数据一致性一样。

知道了上面的这些,那我们接下来就提出问题了:

我们编写的程序如何和CPU交互?是否会被cache住?是否存在并发问题?可能的情况下,如何利用CPU提高程序运行效率?下面就深入底层来了解一下这些过程是如何发生的:

在Java之中,大部分都是申请对象和操作对象,我们都知道对象大部分存在于堆(Heap)之中,那么Java的栈之中存储什么呢?

答案如下:Java 的栈之中更多是 Java 和 OS 一起管理的一块区域,当程序之中的局部变量之中使用基本类型时,其直接在“栈”上申请了一些空间,或者使用引用来引用对象的时候,这些引用的空间也位于“栈”上。

确切的说,在编译阶段,Java 就可以决定方法的“本地变量”(LocalVariable)的个数,因此在方法调用的时候,就可以直接分配一个本地变量的区域。

这个空间是基于 slot 来分配的,每个 slot 占用 32 bit,就算是 boolean也会占用一样的宽度作为一个 slot。当然,long,double 会占用两个 slot。这些 slot 可以被复用,也就是说,在方法体内部,如果某个局部变量时在循环或者判定语句内部声明的,那么在退出这个区域之后,其对应的slot可以被释放给在其之后声明的局部变量使用的。

在程序运行的过程之中,是通过 Java 的虚指令来完成对 Java 虚拟机之中的对象和数据做一些操作。虚指令只是 java 的指令,而不是最终的指令。有虚指令才有跨平台,其最终会在对应OS上面的 JVM被翻译成汇编指令完成对实际硬件的运行操作。

下面是一个常见的例子:

笔者个人记录:

按照书本知识发现javap提示文件不存在,后来发现是要先使用 javac 编译代码,在编译之后才可以使用 javap 来查看代码。自己落了一步而已。

操作步骤应该为:

javac testClassSum.java

javap -verbose testClassSum

注意:此处如果文件编码是 GB2312,那么会报错其编码不是 UTF-8,导致文件无法解析(在 javac 就报错)

下面是 console 之中输出的内容:

Warning: Binary file testClassSum contains JavaTeZhongBing.Chapter2.testClassSum
Classfile /Users/zhouhaiming/CodingProjects/AlgorithmStudy/src/main/java/JavaTeZhongBing/Chapter2/testClassSum.class
  Last modified 9 Mar, 2020; size 314 bytes
  MD5 checksum 7f542c6f111cb4ddc530f5b4b34ef487
  Compiled from "testClassSum.java"
public class JavaTeZhongBing.Chapter2.testClassSum
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#12         // java/lang/Object."<init>":()V
   #2 = Class              #13            // JavaTeZhongBing/Chapter2/testClassSum
   #3 = Class              #14            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               main
   #9 = Utf8               ([Ljava/lang/String;)V
  #10 = Utf8               SourceFile
  #11 = Utf8               testClassSum.java
  #12 = NameAndType        #4:#5          // "<init>":()V
  #13 = Utf8               JavaTeZhongBing/Chapter2/testClassSum
  #14 = Utf8               java/lang/Object
{
  public JavaTeZhongBing.Chapter2.testClassSum();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: istore_3
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 7: 4
        line 8: 8
}
SourceFile: "testClassSum.java"

上面的这部分代码可以分为两部分来看:一部分是常量池描述讯息,一部分是字节码的body 部分。

下面是第一部分的常量池描述信息的内容:

常量池描述信息是在编译时就确定的,常量通常包含:类名,方法名,属性名,类型,修饰符,字符串常量,记录其入口位置(符号#上带一个数字,可以理解为一个入口标志位),一些对象的常量值。

常量池只是一些单纯的“列表”,和程序运行没有很大的关系。在实际运行的过程之中需要组合成有效的运行指令,其在 body 内部。

下面是第二部分的运算指令的内容:

首先是在书中有一部分内容 LocalVariableTable,其内容在上面的代码之中没有,将其补齐:

image-20200309153441406

指令不是重点,由于我们是第一次接触,所以对其做部分分析:

  • iconst_1,将int类型的值1推送到栈顶。
  • istore_1,将栈顶的元素弹出,赋值给第二个 slot 的本地变量。

其综合起来的作用相当于 int a =1;

iconst相关的命令包括:

Iconst_m1,iconst_[0-5],对应虚指令的范围有[0x03~0x08],表示[-1~5]之间的数字常量加载到栈顶,如果不是这个范围的数字,就 bipush 指令。

istore_1 是赋值给 slot 为起始位置的本地变量,istore_0 才是赋值给第一个 slot 起始位置,那么第0个本地变量是什么呢?是 main 方法传入的string[] 参数。同样的,如果是非静态方法,this 将作为任何方法的第一个本地变量

在图中我们也看出来了,几个变量所对应的 slot 编号也输出了,这里面的每个局部变量都单独占用一个 slot,如果局部变量之中有 double,long 等类型,slot 的个数就会变多。

LocalVariableTable列表是本地的列表,某些开发工具默认有这些信息,通过 javac 编译之后的 class 文件默认是没有这些输出信息的。也就是默认情况下本地变量没有名称的概念

LocalVariableTable 列表之中每一行都代表一个本地变量,每一列的解释如下:

  • Start, 代表本地变量在虚指令作用于之中的起始位置,比如第一个本地变量args 是从 0 开始的。
  • Length,代表本地变量在虚指令列表之中的作用域长度,比如第一个本地变量 args 是 9条指令的作用域。
  • Slot,代表本地变量的 slot 的起始位置编号,此处为顺序排布,但是如果出现 long,double 等等就会跳跃。原因上面讲了,是因为一个long占用为2个 slot。
  • Name,代表本地变量的名称,也就是本地定义的名称。

  • Signature,代表本地变量的类型,比如第一个是 String 类型,其他几个都是 Int 类型。

接下来,同样是将int类型的值2推到栈顶并且保存在第三个 slot 的本地变量之中。

iload_1 和 iload_2 是将两个本地变量的值推至栈顶,然后指定一个iadd 操作计算出叠加之后的结果放在栈顶,istore_3 将结果数据从栈顶 pop 出来,保存在第4个slot所对应的本地变量之中。

这里的“栈顶”是一个后进先出的 Stack,可以具体由和CPU 交互的操作数组成,但是其并不等价于本地变量。其可以进行多个操作之后,写回到指定的本地变量之中。或者只是用于读操作,就不需要写回本地变量之中了。

这些指令是 JVM 的虚指令,其可能将某些原来代码的顺序重排,虚指令在翻译成CPU的指令阶段也可能重排序。

本节,我们只需要知道 JVM 能发出指令请求,由 OS 来完成具体工作,JVM 自己本身无法完成计算工作就OK。

2.2.2 多核

多核要发挥最大作用,问题是不要让部分CPU闲置。请求的负载均衡部分就包括:

  • 指令由哪个CPU处理?
  • 同一个数据被多个CPU处理,并且对其进行修改之后,如何让其他CPU知道?

上面这些都是负载均衡的部分。

当发起一个计算请求的时候,例如一个中断,这么多的CPU会干什么呢?这就得从任务的模型开始说起。

  • 一开始,操作系统可能是不断的扫描各个部件查看是否有指令来,有的话就及时处理。但是这种模式往往会有延时,因为”知道的时候都晚了“

  • 后来,CPU有了中断模型,通过中断来完成调用。但是某些行为的中断频率过高(比如鼠标移动,不断发送指令)。那么优化方案就是对中断做一个缓冲区,由于CPU的处理速度很快,可以在瞬间处理大批量的请求,所以缓冲区会很快被清空。
  • 一系列任务可能要做各种各样事情,通常情况下CPU的计算速度很快,但是 OS 不希望CPU因为一个”等待指令“或者长期执行的任务使得自己”陷入困境“。比如一些 I/O 等待,其中途基本都不参与,而是以事件注册的方式来实现回调。而对于某些执行时间长的任务,CPU可能会分配一些时间片来处理其他的任务。

当多个CPU在同一台计算机之中出现的时候,会出现什么情况呢?有以下几种情况:

  1. ”大家一起抢指令“这个是不多见的。只有在某些系统之中有多进程模式,多个进程去监听一个段口,在其得到信号时,可能多个进程同时被唤醒。这种模式叫做”惊群“
  2. 有一个CPU来分配指令,这种方式也存在。虽然在这个情况下很可能此CPU的占用率过高,但是其至少实现了调度,且可以实现一定程度的”资源隔离“。
  3. 按照板块来划分,且每个板块之中有专门的CPU来管理调度区域,也未必合理。首先,数据还需要和其他部分的部件通信,很难实现完全的隔离。其次,板块划分完之后,很可能出现热点问题,某个或者某几个板块过热。

所以并不存在”完美“的方案,要针对程序所处情景和方式进行具体的分配。

2.2.3 Cache line

cache line,从名字上面猜测意义为”cache行“。那么什么叫做一行?为啥要cache 一行?一行有多长?这些又和我编写 Java 程序有什么关系呢?

Cache line 将”连续的一段内存区域“ 进行 Cache,而不是每次就 Cache 一个内存单元。通常在计算机之中以64字节作为一个基本单位进行Cache 操作。

这样在操作相关的数据时,就不需要每次都从内存之中读取了。下面举一个二位数组的例子来说明其效率的差异。

遍历二维数组 int [][]a = new int[5][10]

要遍历这个二维数组,通常用二层循环来遍历,分别遍历两个维度,有两个方法,一种是外层循环第一维,内层循环第二维。另一种是相反的顺序。

这两种顺序哪个效率高呢?

效率高的是第一种。因为在 Java 数组在内存分配之中是先分配第一维,然后再分配多个第二维子数组。也就是说a[0][x]a[1][x] 是两个数组上面的,其空间也自然不在一起。

单个数组的内存空间是连续的,那么当获取 a[0][0] 时,Cache Line会将所有的相关元素,例如a[0][1]等等都Cache到CPU的缓存之中,当使用第一种遍历方式来遍历的时候,这些数据只需要Cache一次就可以完成,但是第二种方式下标访问即为不连续的,那么就没法一次Cache,要多次重新访问内存才行。

问: 如果CPU Cache时时按照连续内存空间进行的,那么对内存进行了修改,回写的时候是不是也是按照行进行的呢?

答:不是,Cache line 的目的是为了快速访问,其对内存单元做出的修改有明确的定义,在回写的时候,也会找到对应的内存空间。

2.2.4 缓存一致性协议

上面讲了 cache line,以及CPU的 cache 作用。下面了解一下缓存一致性协议:

当有来自内存的同一份数据 cache 在多个CPU之中,且要求这些数据的读写一致时,多个CPU之间就得遵循缓存共享的一致性原则。

那这个该如何理解呢:可以理解为很多人都拿到了一份设计报告的模板作为初稿,每个人都拿回去进行修改,但是要求是任何人做出的修改都要让别人知道你对哪些内容做了什么修改,以便于大家可以在你修改的基础上做出进一步的修改。这也就是我们说的一致性。如图所示:

image-20200310143728999

这种模型之中,在CPU上面的实现就对应于多个CPU针对同一个内存单元都有自己的一份拷贝,当某个CPU修改其共享数据时,需要通知其他的CPU数据已经修改。

内存单元有以下几种状态:

  • 修改(Modified)
  • 独占(Exclusive)
  • 共享(Shared)
  • 失效(Invalid)

等等。多个CPU之间通过总线相连。那么每个CPU的Cache处理器除了要相应本身的对应的CPU的读写操作之外,还需要监听总线上面的其他CPU的读写操作,通过这些操作对自己的Cache做相应的处理,形成了一种虚共享,这个叫做MESI协议。

一个数据修改了,需要告诉其他的CPU数据被修改,现代CPU的Intel微架构是使用QPI完成的。但是CPU的交互是有一个时间差的,通常在20~40ns 级别,频繁的交互会使系统性能下降,而且CPU的使用量会飙高。就像一个公司的人过多,那么很多时候都要开会,大家都在忙,但是没做多少事情。

会有怎么样的一种场景呢?

比如设计一个数组来表示一组状态,数组之中多个下标分别表示一种”状态“,每个线程之中独自享有自己的状态,这个设计表面上好像已经脱离了锁,让他更具有性能扩展性。但是并不是这样,举个例子 :

定义一个拥有 volatile 变量的对象数组:

class VolatileInteger {
	volatile int number;
}

final VolatileInteger[]values = new VolatileInteger[10];
		for(int i = 0 ; i < 10 ; i++) {
			values[i] = new VolatileInteger();
		}

那么如果此时有多个CPU使用这个数组的元素,即使每个线程只是操作对应的某个指定的数组下标,那么数组之中的多个元素由于Cache line也可能会被不同的CPU cache 到,每个CPU可能只修改某个元素,依旧会有大量的交互存在,导致其出现大量的QPI现象。

疑问:

Java 的数组对象之中,数组中仅仅包含引用,对象应当有单独的空间。那么不包含内容的怎么会有连续的空间呢?

解答:

Java 的堆内存在分配时,会相对较为连续的分配,这样在某些问题的处理上比较方便(书中并没有讲是哪部分),上面的代码是使用for循环来分配空间的, 那么分配过程很快,没有并发的情况下其是比较连续的。如果发生了GC,那么在同一个对象的引用合并之后,其内存空间也会相对的较为连续,所以Cache line的概率是很高的。

2.2.5 上下文切换

”上下文切换“,(context switch) 之中的”上下文“,也就是内容,简单来说就是内容切换。

问题:为何有内容切换?何时进行内容切换?切换的内容是什么?Java 系统如何做这些事情?

回答:举个例子。比如出门办事有10个步骤,办到一半的时候其发现需要暂时登一下,那么在其等待的时间之内,办理材料的人肯定是要先去给别人办理,当其回来的时候,可能办理材料的是另一个人,不管是谁都得重新看这些材料,从还没完成的步骤向下办理。

那么在计算机之中也是一样。

线程已经执行了一部分内容,需要记录下其内容和状态,中间由于调度算法(比如分时间片,调用让步和休眠等等)或者阻塞等原因,导致还没完全结束的线程离开CPU的调度,此时需要保留现场,以便重新调用的时候可以找到其上次执行的位置。比如在I/O 方面,一次普通的API调用,一次数据库访问,一次磁盘请求,此时线程会陷入 Blocking 状态,以至于发生所谓的”上下文切换“。

大部分知识会提及进程比线程具有更高的开销,进程独立向OS申请资源,而线程是共享进程的资源。下面仔细剖析一下:

目前CPU调度的最基本单位是线程,Java 也是基于多线程模式的,而非多进程模式。由于资源共享,在Java 之中的一个线程如果过大,将一个 JVM 进程直挂死掉,这种情况在多进程模式之中不会发生。

通常在实际运行之中会有代码段和数据段,内容切换的时候就得保存这些上下文信息,需要使用的时候再加载回来,以便知道在哪个位置继续执行,以及执行之中的本地信息。

类似于 log4j 这种开源的日志工具使用的就是异步模式来实现日志的写操作,而程序通常不直接参与这个操作,那么大部分线程就可以直接完成处理。其实现方式是,日志的写操作会直接写入一个消息队列之中,再由单独的线程来完成写操作。

2.2.6 并发和征用

有并发就会发生对各种资源的征用。

当程序之中出现”加锁“的时候,比如Java 的 Synchronized,说明此区域是一块”临界区“,临界区的范围取决于加锁的对象是谁,以及跨越的代码段。如果多个线程尝试进入临界区,那么无论CPU的个数是多少,都只会允许一个线程进入这个区域,而其他的通常会被放入一个等待队列之中,进入 Blocking 状态。

那么如果临界区的代码很长,好比一个”独木桥“只允许一个人在上面玩,且这个桥特别长,那么其极端情况就是从接收到返回全都被锁住,这种情况下哪怕有100个CPU,其性能也是单核的性能或者更低(其征用也会有很多的开销)。

征用带来的更多是同步的开销,其会发出很多指令要求在所有的CPU上不允许其他的线程进入临界区,且将被等待的线程塞入阻塞队列,当前一个线程退出临界区之后还要激活其他被阻塞的线程。

那么我们肯定希望这个独木桥更短一些,变成一截截的独木桥(也就是其粒度更细),在锁的这个大范围内,可以将一个大锁分成很多小锁。这种情况下,也需要对于进程选择部分分出额外的资源来管理,当前线程完成之后,应该在线程之内拿出哪个线程来继续任务呢?是指派还是竞争?

实际业务之中,锁粒度是有很多的优化方法的,且在锁粒度的基础上还会产生很多的乐观机制,这些在后面书中的第五章介绍。

征用CPU的访问不仅仅体现在锁上面,还体现在CPU的数量。CPU本身就是有限的,当有多个线程同时要求一个CPU进行处理时,CPU就会将这些任务队列化,CPU会基于时间片,优先级,任务大小等等分别进行调度,这样就会产生”上下文切换“的问题。

下面讲讲线程池相关的问题:

一开始的新手在配置线程池的时候,往往将”线程池“配置的相当高,但是效果比之前反而更差。

在真实配置线程池的过程之中,对于CPU密集型系统,理想的线程数是CPU+-1,目的很简单,就是让CPU转起来。反过来,如果给系统配置了非常多的线程,那么在调度的时候就会有很多上下文切换,表面上CPU的使用率在不断的上升,但是很大一部分的消耗在进行切换中的加载和保存现场的处理。

这个参数的值并不是绝对的,尤其是在I/O 密集型系统之中,本身就需要大量的线程(和外界交互使用)。并不在乎上下文切换的开销。

对于我们而言,大部分系统都是有一部分的I/O 也会有部分的CPU时间,那么该如何配置线程数呢?下面是分析方法:

  1. 系统CPU密集度

    说白了,就是CPU占用多的程序的比例。

  2. 关键程序的各项时间比例

    1. 普通常规的操作,理论上我们认为自己都需要CPU调度来处理(不管是否需要将数据主内存之中加载到栈,或者是加载到CPU的cache 之中),在最理想的情况下,建议将线程数设置为CPu+-1,因为这样设置之后,理论上是没有线程的上下文切换的,或者说上下文切换的几率会小很多。(排除系统本身和其他的进程也需要CPU)

    2. I/O 操作(包括网络I/O,磁盘I/O,屏幕输出I/O 等等),甚至很多人会说,如果不做系统之间的调用就不会用到网络I/O吧?其实不是,因为数据库,缓存操作等等也都是网络I/O。哪怕是 System.out.println() 也是一种I/O。在这里提到的都是阻塞I/O(在非阻塞I/O 之中,有一个非常短暂的和Kernel 交互的停顿,而上下文信息很多是通过程序保存的,因此也是根据实际场景来说的)。前面也提到过,在等待I/O 相应的时间范围之内,允许其他线程访问 CPU,不然被挂起的就不是线程,而是CPU了。

      例如,系统之中的一段程序访问一共耗费了 120 ms,但是I/O 操作耗费了100 ms,那么意味着在这100ms时间之内的CPU是可以被其他线程访问的。此时,这个程序在单核系统之中的线程数理论上可以设置为6,多核系统在这个6上面还可以乘以核的个数。但是这部分只是理论数字,可能存在很多的实际情况还需要进一步的测试确定。

    3. “锁”,也就是临界区的范围。前面提到了其有粒度,在这个区域之内无论多少个CPU,也无法同时进入,因此这部分要按照关键程序来计算(所谓关键程序就是经常访问的程序)。如果在锁内发生了I/O 操作,或者是有大量的循环,递归等等,那么其就必须等待这些完成之后才可以有下一个线程进入处理。关键程序会导致真正并发的时候,很多线程阻塞在这个位置。

      此时计算线程数,首先看这个锁的对象是不是静态对象或者class,如果是的话,那么配置多少个线程效果都是一样的(线程多的时候还得等待分配,效率可能更差)。且这个时候不可以乘以CPU的个数,因为锁是全局的,多核也无法并行处理临界区的内容,其和CPU的个数无关。

  3. JVM 本身的调节。

    不管CPU跑的多快,如果JVM在不断的做GC操作,或者因为其他因素跑的“慢死”,那么怎么配置线程池,也是没办法跑得快。我们可以根据 JVM 运行日志,平均做 Young GC的时间间隔(通过 Yong GC与运行时长对比),以及系统的 QPS(每秒的请求数),来估算每个请求大致占用的内存大小,虽然不是很准,但是有一定的参考价值。

2.3 内存

下面只是简要介绍一下:地址位数,逻辑地址和物理地址,内核区域和进程分配,JVM 内存核其他进程内存的区别,内存和CPU,内存和磁盘等等。

之前提到过CPU的Cache效率比访问内存的效率高,主要体现在延迟上,因为内存距离CPU有一段距离。但是通常系统之中需要计算的数据量级都很大,CPU的Cache肯定不够。

下面具体讲一下各个地址之间的关系:

以32位的OS为例,最基础的是物理地址,系统为每个内存单元编写一些物理地址(其是地址总线上面的对应关系,每个地址位上的 0 1 都可以表达出不同的地址。物理地址可以认为是唯一的,由内存本身的电路来控制。

所有在”程序中“使用的地址都是虚拟地址(也叫逻辑地址),其在不同的进程之间可以重复,所以才叫虚拟地址。通过虚拟地址加上偏移量之后就可以拿到真实地址。

如果没有分页机制,那么每个程序都会有一个”段“的概念,也就是为进程分配的一段内存区。其使用起始位置+逻辑地址(此时逻辑地址的意义和偏移量一样)得到线性地址,而此时每个程序段是单独的一块连续区域,所以线性地址就是物理地址。

现代CPU启动了分页模型,其会将内存区域分成比较小的页,物理上面大部分分成4KB/页,管理这些页的情况如下:

系统为每个进程分配页目录,这个页目录也是一个页(大小也是4KB),这些页是由Kernel 来管理的(因此Kernel 需要单独的区域,程序使用的地址不是从0开始的)。当使用一个虚拟地址访问内存的时候,会寻找对应进程的页目录的地址,加载到CR3寄存器之中。

首先页目录是4KB大小,我们都知道在32位地址之中,每4个字节可以存放一个地址,所以其一共可以存放4KB/ 4 = 1024 个地址,那么其就是 0 ~ 1023。那么我们只要传入的逻辑地址中间10个二进制位就可以标志出页表之中的对应单元(每4字节位一个标志地址的单元)。

在Java之中,其参数 -Xms 是代表 Java 申请的一块大内存,其是通过 OS 预先分配的实际物理内存空间。但是 -Xmx 并不是 OS 一开始就会分配的空间,许多空间是真正使用的时候才分配的, 甚至可以设置为比物理内存大几十倍这样。

线性地址分配好之后,自己来虚拟分配内存空间,对象分配内存的时候由 JVM 和 OS 交互。但是垃圾回收必须由 JVM 做,原因是对于 OS 来说,其认为这些地址都已经给了 JVM了,对其而言并不知道状态。

进程向 OS 申请的内存空间首先会从 Free 状态到 Reserved 状态,这种状态仅仅是少了一个坑,但是还没有真正使用这个内存空间,真正使用的内存页的状态位 Commited。

而 JVM 这块大内存要求逻辑地址是连续的,所以比较苛刻。在 32 位系统之中,1.5 GB 的Heap 区域是比较合适的,因为JVM 除了这部分之外还需要使用很多空间,必须为这些开销预留空间。

2.4 磁盘

几个指标:

  1. IOPS:磁盘每秒最多可以完成的 I/O 次数。这个I/O一般指的是小I/O,很大的数据读写也是被分解成多次的小I/O 进行操作。
  2. 随机读写:这是一个相对的概念。磁盘在发生读/写的操作之中,需要寻找位置再进行读/写操作。由于频繁的发生I/O 请求,所以就频繁的寻找位置,看起来像随机读写。
  3. 顺序读写:相对于随机读写,每次读写的数据内容比较大,比如1MB(过大的数据读写也会拆分成多个小的 I/O 请求)。简单来说,找对了位置的话,其处理1MB 和 处理 4KB的数据时间差不多。

2.5 缓存

2.5.1 缓存的相对性

作者认为,缓存是相对的概念,只要”就近“就可以认为是缓存。

2.5.2 缓存的用途和场景

缓存用于读多写少的场景,且要考虑分布式缓存的同步问题。通常我们会满足”非强制一致性“,可以利用日志,冗余等方法来完成丢失数据的回补工作,甚至有些直接放在内存之中。但是比起来宕机的概率,这种性能优势是完全值得的。

2.6 关于网络和数据库

2.6.1 Java 基本 IO

问题:怎么理解CPU呢?CPU不也是和内存通信吗?

回答:内存之中的数据最终由OS调度给CPU进行处理,也就是其他设备要处理的数据通常先放到内存之中,然后再得到处理。而内存之中被处理好的数据反馈到某个设备上,这才是输入/输出。CPU是处理的实体,不算 I/O设备。

问题:经常见到的I/O有哪些?

回答:比如向硬盘之中写入数据,或者从硬盘之中读取数据,这也是I/O,和远程的程序通信也是一种I/O,诸如此类和外界沟通的角色都是I/O。

2.6.2 Java的网络基本规则

问:计算机 I/O 交互在网络当中是什么样子的?

答:I/O 传送数据内在都是基于 byte的,当然也可以说是二进制数据,不管程序是通过字符还是对象等等方式。都是经过中间层转换成字节之后发送到I/O 端进行交互,或者从 I/O 端读取二进制数据转换为字符与对象

在Java之中的字符集编解码有什么要注意的地方?

Java 之中,包括计算机之中,都只是传输一连串的二进制编码。而接收方和发送方会将对应的编码。双方的约定就是字符集,也就是字符和字节转换过程之中的公式或者是映射表。(但是并不代表每一种字符集对同一个字符的编码是一样的数字,也并代表一种字符集的字符编码数字在另一种字符集之中肯定可以找到,那么在I/O 交互的过程之中就有可能出现所谓的乱码问题。

发送方使用什么编码,接收方就得使用什么解码。如果发送方使用 UTF-8 编码,但是接收方使用 GBK 来解码,那么肯定得不到正确的结果,而且一旦转错了,就没有办法再转回来。但是有的字符集是可以转回来的,比如 ISO8859-1。

有一篇文章说这个讲解的很好:https://germinate.github.io/2016/GBK%E4%B8%8EUTF-8%E7%BC%96%E7%A0%81%E9%94%99%E8%AF%AF%E8%BD%AC%E6%8D%A2%E5%90%8E%EF%BC%8C%E6%97%A0%E6%B3%95%E6%81%A2%E5%A4%8D/

其简要而言就是GBK也好,UTF-8 也好,在遇到自己无法解析的字符时候会使用 ?(GBK) 和 �(UTF-8) 来替代,从而改变了原始的字节流的信息,所以无法再次转码回来。

那么接收方怎么自动知道发送方所发送的字符集是什么?

在真正编码的时候,接收方要知道正确的编码字符集有两种方式:

  1. 双方约定好编码,然后再进行交互
  2. 在数据的头部包装字符集,比如在 contentType 之中的参数作为传输数据类型,字符集的标识,从浏览器向服务器提交数据就可以包装在头部。

接收方如何知道字节流结束呢?

  1. 双方约定一种结束标记,可以是一些字符串(相对更靠谱)
  2. 或者是一些可以识别的字符串(不靠谱,但是非常简单)
  3. 或者在传输头部的指定位置标记 body 部分的字节长度

接收消息结束之后,只能代表本次交互结束,会不会有后续的交互,双方要不要再保持链接,保持多长的连接,都由自己决定。

Java 之中的所有 I/O 都会经历一个Socket和Channel的过程。内在的基本原理差别不大,在输出数据时,一般都会:

先经过 JVM, 然后到 OS 管理的一块内核态区域,再由 Kernel 去完成输出的交互。输出到磁盘或者网络也是一样。

输入的过程刚好相反,只是在输入过程之中通常程序会发生阻塞,也就是在程序发生 read() 操作的时候,在 I/O 还未响应之前(Kernel 还没准备好数据之前),线程会被阻塞(Blocking)。

为何程序要经历那么多的拷贝才能做I/O?能否能少拷贝几次?

因为 JVM 的内存是自己管理的,是虚拟出来的,数据都在 JVM 之中,要输出数据其实最终要使用 OS和网络,理论上必要的拷贝是必然的。而能否少做 I/O,某些场景下的确可以做到(例如可以使用 DirectBuffer 来完成)。

2.6.3 Java 和数据库的交互

就目前来看,大部分 Java 和 数据库之间的交互 API 还是基于 Java 的 Socket,并且都是 BIO(Block IO) 模型,不管是 RDBMS 还是 NOSQL 都是一样。

Java 与数据库通信在 Socket 上面会建立很多不同的协议,JDBC 是其中的一种。以JDBC为例。数据库服务器此时就类似于 SocketServer 提供 Socket 服务,内在提供配套的线程池来对应连接进行处理。

Java 本身通过 JDBC 的驱动程序和数据库端传送的相应的指令来得到对应的结果。驱动程序完成了对 Socket 的协议包装。

数据库服务器端的处理方式也非常复杂,不同的数据库处理线程和资源的分配关系也是不同的,也有一些数据库,比如 Oracle ,就是以进程为单位进行处理的,而且要分配一个 5MB 左右的用户区域。那么自然 Oracle 新建一个连接的开销要大很多,但是用起来要顺很多。MySQL 不是这样。

JDBC 通常有 Java 语言标准接口,由第三方或者数据库厂商来提供驱动程序的实现,其实现的主要组成接口有Driver, Connection, DataSource, Statement, ResultSet 等等。

  1. Driver: 用于注册到 DriveManager 之中,并且提供对外的统一创建 Connection 的接口。
  2. Connection:连接的对象,或者说包装了连接操作的对象。
  3. Datasource:对 Connection 操作的相关管理 API,。实现一般是第三方提供。
  4. Statement:通用的语句级别对象处理,其子类接口为 PreparedStatement
  5. ResultSet:返回值结果集的一个接口,包含对结果集操作的规范 API 定义。