深入理解Java虚拟机::虚拟机字节码执行引擎

虚拟机字节码执行引擎 概述 执行引擎是 Java 虚拟机核心的组成部分之一 虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。 运行时栈帧结构 Java 虚拟机以方法作为最基本的执行单元 栈帧是用于支持虚拟机进行方法调用和方法执行背后的数据结构 虚拟机运行时数据区中的虚拟机栈的栈元素 栈帧 局部变量表、操作数栈、动态连接和方法返回地址等信息 每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程 一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式 局部变量表 局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量 局部变量表的容量以变量槽为最小单位 64 位或者 32 位 每个变量槽都应该能存放一个 boolean、 byte、char、short、int、float、reference 或 returnAddress 类型的数据 局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。 当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递, 实例方法, 那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。 变量槽可以复用,但是会影响垃圾回收 把占用了大量内存但是已经用不到的变量设为 null 有助于 gc 的回收,因为在局部变量表中不再有引用 操作数栈 后入先出栈 动态连接 支持方法调用过程中的动态连接 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数 这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接 方法返回地址 当一个方法开始执行后,只有两种方式退出这个方法 遇到任意一个方法返回的字节码指令 代码中使用 athrow 字节码指令产生的异常 方法退出的过程实际上等同于把当前栈帧出栈 恢复上层方法的局部变量表和操作数栈 把返回值压入调用者栈帧的操作数栈中 调整 PC 计数器的值以指向方法调用指令后面的一条指令等 方法调用 方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还未涉及方法内部的具体运行过程 某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。 解析 方法调用的目标方法在 Class 文件里面都是一个常量池中的符号引用 方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的 在 Java 虚拟机支持以下 5 条方法调用字节码指令,分别是: invokestatic。用于调用静态方法。 invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。 invokevirtual。用于调用所有的虚方法。 invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。 invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面 4 条调用指令,分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的。 final 方法无法被覆盖 分派 静态分派 编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。 自动转型按照 char > int > long > float > double 的顺序转型进行匹配 动态分配 重写 invokevirtual 指令的运行时解析过程 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C。 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回 java.lang.IllegalAccessError 异常。 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。 单分派与多分派 如今的 Java 语言是一门静态多分派、动态单分派的语言 虚拟机动态分派的实现 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。 动态类型语言支持 invokedynamic 实现动态类型语言 动态类型语言 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的 静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。 动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。 Java 与动态类型 invokedynamic 指令以及 java.lang.invoke 包提供了 JVM 底层对于动态语言的实现的支持 java.lang.invoke 包 方法句柄 Java 语言也可以拥有类似于函数指针或者委托的方法别名这样的工具 Reflection 和 MethodHandle 机制本质上都是在模拟方法调用,但是 Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用 invokedynamic 指令 支持动态调用方法 基于栈的字节码解释执行引擎 解释执行 在 Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。 基于栈的指令集与基于寄存器的指令集 Javac 编译器输出的字节码指令流 一种基于栈的指令集架构 基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供 代码相对更加紧凑 编译器实现更加简单 栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些 完成相同功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈操作本身就产生了相当大量的指令

2021 Apr 07 · 1 min

深入理解Java虚拟机::虚拟机类加载机制

虚拟机类加载机制 概述 Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制 类加载的时机 一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unload ing)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking) 加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的 解析在某些情况下可以在初始化阶段之后再开始 为了支持 Java 语言的运行时绑定特性 JVM 规范严格规定了有且只有六种情况必须立即对类进行初始化 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段 使用 new 关键字实例化对象的时候 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候。 调用一个类型的静态方法的时候 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。 当使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。 这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。 类加载的过程 加载 在加载阶段,Java 虚拟机需要完成以下三件事情: 通过一个类的全限定名来获取定义此类的二进制字节流。 从 ZIP 压缩包中读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础 从网络中获取,这种场景最典型的应用就是 Web Applet。 运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件。 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。 可以从加密文件中获取,这是典型的防 Class 文件被反编译的保护措施,通过加载时解密 Class 文件来保障程序运行逻辑不被窥探。 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。 非数组类型的加载既可以使用 Java 虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成 数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上 如果数组的组件类型不是引用类型, Java 虚拟机将会把数组 C 标记为与引导类加载器关联。 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public,可被所有的类和接口访问到。 加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了 类型数据妥善安置在方法区之后,会在 Java 堆内存中实例化一个 java.lang.Class 类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。 验证 验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。 文件格式验证 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理 元数据验证 对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求 字节码验证 通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的 符号引用验证 对类自身以外的各类信息进行匹配性校验 -Xverify:none 参数用来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。 准备 准备阶段是正式为类中定义的变量分配内存并设置类变量初始值的阶段 解析 解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程 符号引用 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可 内存布局无关 直接引用 直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄 内存布局直接相关 虚拟机实现可以对第一次解析的结果进行缓存, invokedynamic 除外,针对动态语言支持 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行 初始化 类的初始化阶段是类加载过程的最后一个步骤 根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源 与构造函数不同,编译期生成的私有方法 父类构造器优先子类构造器,父类静态块优先子类静态块 类加载器 类与类加载器 类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远超类加载阶段 每一个类加载器,都拥有一个独立的类名称空间 比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义 双亲委派模式 启动类加载器,C++语言实现,是虚拟机自身的一部分 其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。 启动类加载器 负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的类库加载到虚拟机的内存中 扩展类加载器 这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库 应用程序类加载器 由 sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是 ClassLoader 类中的 getSystem ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。 java.lang 开头的包因为安全原因无法被加载 破坏双亲委派模式 loadClass()加载类, 历史遗留问题 JNDI 服务, 线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。 追求动态性,热部署,热加载 在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi 将按照下面的顺序进行类搜索: 将以 java.*开头的类,委派给父类加载器加载。 否则,将委派列表名单内的类,委派给父类加载器加载。 否则,将 Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载。 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载。 否则,类查找失败。 Java 模块化系统 实现模块化的关键目标可配置的封装隔离机制 模块就可以声明对其他模块的显式依赖,这样 Java 虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常。 可配置的封装隔离机制还解决了原来类路径上跨 JAR 文件的 public 类型的可访问性问题。JDK 9 中的 public 类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些 public 的类型可以被其他哪一些模块访问 兼容性 保持向后兼容 模块带有版本号,需要手动选择版本号打包 类加载器 扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代 <JAVA_HOME>\lib\ext 目录被舍弃 平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader 当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,

2021 Apr 06 · 2 min

深入理解Java虚拟机::类文件结构

类文件结构 实现语言无关性的基础是虚拟机和字节码存储格式 Class 类文件的结构 Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型无符号数和表 无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上也可以视作是一张表, ClassFile { u4 magic; //0xCAFEBABE u2 minor_version; //class file minor version u2 major_version; //class file major version u2 constant_pool_count; //count of entries in next item cp_info constant_pool[constant_pool_count-1]; //constants u2 access_flags; //class assess flags u2 this_class; //index of this class to const pool u2 super_class; //index of super class to const pool u2 interfaces_count; //number of interfaces implemented u2 interfaces[interfaces_count];//indices of interfaces u2 fields_count; //number of fields in the class field_info fields[fields_count];//fields descriptions u2 methods_count; //number of methods in the class method_info methods[methods_count]; //methods descriptions u2 attributes_count; //number of attributes of the class attribute_info attributes[attributes_count]; //attributes } 魔数与 Class 文件的版本 魔数 每个 Class 文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。 Class 文件的版本号 第 5 和第 6 个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。 高版本需要向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件。 常量池 常量池的入口放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count) Class 文件结构中只有常量池的容量计数是从 1 开始, 0 用于表示无常量池引用 常量池中主要存放两大类常量: 字面量(Literal)和符号引用(Symbolic References) 字面量 文本字符串 声明为 final 的常量值 符号引用 被模块导出或者开放的包 类和接口的全限定名 字段的名称和描述符 方法的名称和描述符 方法句柄和方法类型 动态调用点和动态常量 常量池中的每一项常量都是一个表,互相之间的结构都不相同 JVM 规范中关于 Class 文件格式的章节 访问标志 识别一些类或者接口层次的访问信息 有 16 个标志位可以使用,当前只定义了其中 9 个 类索引、父类索引与接口索引集合 类索引用于确定这个类的全限定名 父类索引用于确定这个类的父类的全限定名 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 关键字后的接口顺序从左到右排列在接口索引集合中。 字段表集合 字段表用于描述接口或者类中声明的变量 类级变量以及实例级变量,但不包括在方法内部声明的局部变量 字段表集合中不会列出从父类或者父接口中继承而来的字段 方法表集合 volatile 关键字和 transient 关键字不能修饰方法 Java 语言中,重载方法需要签名不同 Class 文件格式中,允许方法签名相同,返回值不同的方法存在 属性表集合 Code 属性 Java 程序方法体里面的代码经过 Javac 编译器处理之后,最终变为字节码指令存储在 Code 属性内。 接口和抽象方法没有 Code 属性 《Java 虚拟机规范》中明确限制了一个方法不允许超过 65535 条字节码指令 Exceptions 属性 Exceptions 属性的作用是列举出方法中可能抛出的受查异常 LineNumberTable 属性 LineNumberTable 属性用于描述 Java 源码行号与字节码行号(字节码的偏移量)之间的对应关系 非必需,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号 LocalVariableTable 及 LocalVariableTypeTable 属性 LocalVariableTable 属性用于描述栈帧中局部变量表的变量与 Java 源码中定义的变量之间的关系 非必需,影响是当其他人引用这个方法时,所有的参数名称都将会丢失 SourceFile 及 SourceDebugExtension 属性 SourceFile 属性用于记录生成这个 Class 文件的源码文件名称 非必需,当抛出异常时,堆栈中将不会显示出错代码所属的文件名 ConstantValue 属性 ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值。只有被 static 关键字修饰的变量(类变量)才可以使用这项属性。 InnerClasses 属性 InnerClasses 属性用于记录内部类与宿主类之间的关联。 如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成 InnerClasses 属性。 Deprecated 及 Synthetic 属性 Deprecated 和 Synthetic 两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。 Deprecated 属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过代码中使用@deprecated注解进行设置。 StackMapTable 属性 目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。 Signature 属性 它是一个可选的定长属性,可以出现于类、字段表和方法表结构的属性表中 Signature 属性会为记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为 Java 语言的泛型采用的是擦除法实现的伪泛型,字节码(Code 属性)中所有的泛型信息编译(类型变量、参数化类型)在编译之后都通通被擦除掉 BootstrapMethods 属性 用于保存 invokedynamic 指令引用的引导方法限定符。 MethodParameters 属性 MethodParameters 的作用是记录方法的各个形参名称和信息。 模块化相关属性 Module 属性是一个非常复杂的变长属性,除了表示该模块的名称、版本、标志信息以外,还存储了这个模块 requires、exports、opens、uses 和 provides 定义的全部内容 运行时注解相关属性 RuntimeVisibleAnnotations 是一个变长属性,它记录了类、字段或方法的声明上记录运行时可见注解,当我们使用反射 API 来获取类、字段或方法上的注解时,返回值就是通过这个属性来取到的 字节码指令简介 Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字以及跟随其后的零至多个代表此操作所需的参数构成 加载和存储指令 加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输 运算指令 算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶 大体上运算指令可以分为两种: 对整型数据进行运算的指令与对浮点型数据进行运算的指令。 类型转换指令 类型转换指令可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作 对象创建与访问指令 虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令(在下一章会讲到数组和普通类的类型创建过程是不同的)。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素 操作数栈管理指令 用于直接操作操作数栈的指令 控制转移指令 控制转移指令可以让 Java 虚拟机有条件或无条件地从指定位置指令的下 一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改 PC 寄存器的值 方法调用和返回指令 用于方法调用 异常处理指令 显式抛出异常的操作都由 athrow 指令来实现 同步指令 Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程来实现 公有设计,私有实现 《Java 虚拟机规范》描绘了 Java 虚拟机应有的共同程序存储格式:Class 文件格式以及字节码指令集。这些内容与硬件、操作系统和具体的 Java 虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看作程序在各种 Java 平台实现之间互相安全地交互的手段。 一个优秀的虚拟机实现,在满足《Java 虚拟机规范》的约束下对具体实现做出修改和优化也是完全可行的,并且《Java 虚拟机规范》中明确鼓励实现者这样去做。只要优化以后 Class 文件依然可以被正确读取,并且包含在其中的语义能得到完整保持,那实现者就可以选择以任何方式去实现这些语义,虚拟机在后台如何处理 Class 文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可 Class 文件结构的发展 Class 文件结构一直处于一个相对比较稳定的状态,Class 文件的主体结构、字节码指令的语义和数量几乎没有出现过变动,所有对 Class 文件格式的改进,都集中在访问标志、属性表这些设计上原本就是可扩展的数据结构中添加新内容。 二十余年间,字节码的数量和语义只发生过屈指可数的几次变动,例如 JDK 1.0.2 时改动过 invokespecial 指令的语义,JDK 7 增加了 invokedynamic 指令,禁止了 ret 和 jsr 指令。

2021 Apr 05 · 2 min

深入理解Java虚拟机::调优案例分析与实战

调优案例分析与实战 大内存硬件上的程序部署策略 目前单体应用在较大内存的硬件上主要的部署方式有两种: 通过一个单独的 Java 虚拟机实例来管理大量的 Java 堆内存。 同时使用若干个 Java 虚拟机,建立逻辑集群来利用硬件资源。 面临的问题 回收大块堆内存而导致的长时间停顿,自从 G1 收集器的出现,增量回收得到比较好的应用 大内存必须有 64 位 Java 虚拟机的支持,但由于压缩指针、处理器缓存行容量(Cache Line)等因素,64 位虚拟机的性能测试结果普遍略低于相同版本的 32 位虚拟机。 必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生堆转 储快照,哪怕成功生成了快照也难以进行分析;如果确实出了问题要进行诊断,可能就必须应用 JM C 这种能够在生产环境中进行的运维工具。 相同的程序在 64 位虚拟机中消耗的内存一般比 32 位虚拟机要大,这是由于指针膨胀,以及数据类 型对齐补白等因素导致的,可以开启压缩指针功能来缓解。 集群间同步导致的内存溢出 集群同步软件的问题导致了内存泄漏 -XX:+HeapDumpOnOutOfMemoryError 堆外内存导致的溢出错误 直接内存:可通过-XX:MaxDirectMemorySize 调整大小,内存不足时抛出 OutOfMemoryError 或者 OutOfMemoryError:Direct buffer memory。 线程堆栈:可通过-Xss 调整大小,内存不足时抛出 StackOverflowError(如果线程请求的栈深度大 于虚拟机所允许的深度)或者 OutOfMemoryError(如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时 无法申请到足够的内存)。 Socket 缓存区:每个 Socket 连接都 Receive 和 Send 两个缓存区,分别占大约 37KB 和 25KB 内存,连接 多的话这块内存占用也比较可观。如果无法分配,可能会抛出 IOException:Too many open files 异常。 JNI 代码:如果代码中使用了 JNI 调用本地库,那本地库使用的内存也不在堆中,而是占用 Java 虚 拟机的本地方法栈和本地内存的。 虚拟机和垃圾收集器:虚拟机、垃圾收集器的工作也是要消耗一定数量的内存的。 外部命令导致系统缓慢 执行这个 Shell 脚本是通过 Java 的 Runtime.getRuntime().exc()方法来调用的。这种调用方式可以达到执行 Shell 脚本的目的,但是它在 Java 虚拟机中是非常消耗资源的操作,即使外 部命令本身能很快执行完毕,频繁调用时创建进程的开销也会非常可观。 服务器虚拟机进程崩溃 由于 MIS 系统的用户多,待办事项变化很快,为了不被 OA 系统速度拖累,使用了异步的方式调用 Web 服务,但由于两边服务速度的完全不对等,时间越长就累积了越多 Web 服务没有调用完成,导致在等待的线程和 Socket 连接越来越多,最终超过虚拟机的承受能力后导致虚拟机进程崩溃。通知 OA 门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。 不恰当数据结构导致内存占用过大 业务上需要每 10 分钟加载一个约 80M B 的数据文件到内存进行数据分析,这些数据会在内存中形成超过 100 万个 HashMap<Long,Long>Entry,在这段时间里面 Minor GC 就会造成超过 500 毫秒的停顿,对于这种长度的停顿时间就接受不了了 如果不修改程序,仅从 GC 调优的角度去解决这个问题,可以考虑直接将 Survivor 空间去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0 或者-XX:+Always-Tenure),让新生代中存活的对象在第一次 Minor GC 后立即进入老年代,等到 Major GC 的时候再去清理它们。 由 Windows 虚拟内存导致的长时间停顿 程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生垃圾收集时就有可能因为恢复页面文件的操作导致不正常的垃圾收集停顿。 在 Java 的 GUI 程序中要避免这种现象,可以加入参数“- Dsun.awt.keepWorkingSetOnMinimize=true”来解决 由安全点导致长时间停顿 解决问题的第一步是把这两个特别慢的线程给找出来,这个倒不困难,添加-XX: +SafepointTimeout 和-XX:SafepointTimeoutDelay=2000 两个参数,让虚拟机在等到线程进入安全点的时间超过 2000 毫秒时就认定为超时,这样就会输出导致问题的线程名称 方法调用、循环跳转、异常跳转这些位置都可能会设置有安全点,但是 HotSpot 虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用 int 类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop ),相对应地,使用 long 或者范围更大的数据类型作为索引值的循环就被称为不可数循环 (Uncounted Loop),将会被放置安全点。通常情况下这个优化措施是可行的,但循环执行的时间不单单是由其次数决定,如果循环体单次执行就特别慢,那即使是可数循环也可能会耗费很多的时间。 清理连接的索引值就是 int 类型,所以这是一个可数循环,HotSpot 不会在循环中插入安全点。当垃圾收集发生时,如果线程刚好执行到该函数里的可数循环时,则必须等待循环全部跑完才能进入安全点,此时其他线程也必须一起等着,所以从现象上看就是长时间的停顿。找到了问题,解决起来就非常简单了,把循环索引的数据类型从 int 改为 long 即可,但如果不具备安全点和垃圾收集的知识,这 种问题是很难处理的。

2021 Apr 04 · 1 min

深入理解Java虚拟机::虚拟机性能监控、故障处理工具

虚拟机性能监控、故障处理工具 jps 虚拟机进程状况工具 可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一 ID(LVMID,Local Virtual Machine Identifier) jstat 虚拟机统计信息监视工具 显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据 jinfo 是实时查看和调整虚拟机各项参数 System.getProperties() jmap 生成堆转储快照 查询 finalize 执行队列 Java 堆和方法区的详细信息 如空间使用率 当前用的是哪种收集器 jhat 与 jmap 搭配使用,来分析 jmap 生成的堆转储快照 jstack 用于生成虚拟机当前时刻的线程快照

2021 Apr 03 · 1 min

深入理解Java虚拟机::垃圾收集器与内存分配策略

垃圾收集器与内存分配策略 垃圾回收需要完成的三件事 那些内存需要回收 什么时候回收 如何回收 当垃圾回收成为性能瓶颈时,对垃圾回收进行必要的监控和调整 需要回收的区域 方法区 内存堆 对象已死 引用计数法 在对象中添加一个引用计数器 每当有一个地方引用它时,计数器值就加一 当引用失效时,计数器值就减一 任何时刻计数器为零的对象就是不可能再被使用的 优点 原理简单,判定效率也很高 缺点 有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作 如单纯的引用计数就很难解决对象之间相互循环引用的问题 可达性分析 通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的 GC Roots 在虚拟机栈(栈帧中的本地变量表)中引用的对象 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量 方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象 Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器 所有被同步锁(synchronized 关键字)持有的对象 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。 其他临时性加入的对象 引用 强引用是程序代码之中普遍存在的引用赋值 软引用是用来描述一些还有用,但非必须的对象 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。 回收方法区 废弃的常量和不再使用的类型 判断一个类不再被使用 该类所有的实例都已经被回收 加载该类的类加载器已经被回收 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法 垃圾回收算法 分代收集理论 绝大多数对象都是朝生夕灭的 熬过越多次垃圾收集过程的对象就越难以消亡 跨代引用相对于同代引用来说仅占极少数 标记清除算法 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象 主要缺点 执行效率不稳定,标记和清除两个过程的执行效率都随对象数量增长而降低 标记、清除之后会产生大量不连续的内存碎片 标记复制算法 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉 空间浪费 优化 新生代中的对象有 98%熬不过第一轮收集 把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8∶1 当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保 标记整理算法 用于老年代回收 标记过程仍然与标记清除算法一样 整理时让所有存活的对象都向内存空间一端移动 STW 的影响 是否移动对象需要考虑延迟和吞吐 优化 平时采用标记清除,当内存碎片化到影响对象分配的时候采用标记整理 HotSpot 的算法实现细节 根节点枚举 根节点枚举始终必须在一个能保障一致性的快照中进行 枚举根节点时是必须要停顿的, STW 准确式垃圾收集 用 OopMap 的数据结构来直接得到哪些地方存放着对象引用的 一旦类加载动作完成,HotSpot 会把对象偏移量上的数据类型计算出来 安全点 只是在“特定的位置”记录了这些信息 强制要求必须执行到达安全点后才能够暂停 安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的 抢先式中断 在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上 主动式中断 当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起 安全区域 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。 记忆集与卡表 解决对象跨代引用所带来的问题 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构 卡表 字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page) 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。 写屏障 解决卡表元素如何维护的问题 写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令 伪共享问题 并发的可达性问题 从 GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必定会与 Java 堆容量直接成正比例关系了 三色标记 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过 对象消失的问题 赋值器插入了一条或多条从黑色对象到白色对象的新引用 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用 增量更新 当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。 原始快照 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。 经典垃圾回收器 Serial 收集器 使用一个处理器或一条收集线程去完成垃圾收集工作 额外内存消耗最小 简单而高效 Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择 ParNew 收集器 Serial 收集器的多线程并行版本 Parallel Scavenge 收集器及 G1 收集器等都没有使用 HotSpot 中原本设计的垃圾收集器的分代框架,而选择另外独立实现,CMS 只能和 ParNew 搭配使用 Parallel Scavenge 收集器 基于标记-复制算法实现的收集器 达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值 最高效率地利用处理器资源 Serial Old 收集器 Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器 使用标记-整理算法 供客户端模式下的 HotSpot 虚拟机使用 Parallel Old 收集器 Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集 基于标记-整理算法实现 注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器这个组合 CMS 收集器 获取最短回收停顿时间为目标的收集器 标记-清除算法 初始标记(CMS initial mark) 仅仅只是标记一下 GC Roots 能直接关联到的对象 并发标记(CMS concurrent mark) 从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程 重新标记(CMS remark) 修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录 并发清除(CMS concurrent sweep) 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的 其中初始标记、重新标记这两个步骤仍然需要“Stop The World” 缺点 对处理器资源非常敏感 CMS 默认启动的回收线程数是(处理器核心数量 +3)/4 CMS 收集器无法处理“浮动垃圾” CMS 的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉 空间碎片 G1 收集器 开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式 提供并发的类卸载的支持 主要面向服务端应用的垃圾收集器 JDK9 的默认收集器 面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式 基于 Region 的堆内存布局 G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果 Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象 对于那些超过了整个 Region 容量的超级大对象, 将会被存放在 N 个连续的 Humongous Region 之中 使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率 Region 里面存在的跨 Region 引用对象如何解决 使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集的应用其实要复杂很多,它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内 根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%至 20%的额外内存来维持收集器工作 并发标记阶段如何保证收集线程与用户线程互不干扰地运行 原始快照算法 如果内存回收的速度赶不上内存分配的速度, G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间“Stop The World” 怎样建立起可靠的停顿预测模型 G1 收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息 收集过程 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象。 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。 G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的 最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率 (Allocation Rate),而不追求一次把整个 Java 堆全部清理干净 G1 垃圾收集产生的内存占用(Footprint)和程序运行时的额外执行负载 (Overload)都要比 CMS 要高 G1 的记忆集(和其他内存消耗)可能会占整个堆容量的 20%乃至更多的内存空间 G1 对写屏障的复杂操作要比 CMS 消耗更多的运算资源 目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间 低延迟垃圾收集器 ZGC 收集器 在尽可能对吞吐量影响不太大的前提下,把垃圾收集的停顿时间限制在十毫秒以内的低延迟 ZGC 收集器是一款基于 Region 内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器 ZGC 的 Region 具有动态性——动态创建和销毁,以及动态的区域容量大小 小型 Region(Small Region):容量固定为 2MB,用于放置小于 256KB 的小对象。 中型 Region(Medium Region):容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象。 大型 Region(Large Region):容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象 ZGC 收集器有一个标志性的设计是它采用的染色指针技术 染色指针是一种直接将少量额外的信息存储在指针上的技术 ZGC 用地址的高 4 位提取出来存储四个标志信息 其引用对象的三色标记状态 是否进入了重分配集(即被移动过) 是否只能通过 finalize()方法才能被访问到 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作 ZGC 只使用了读屏障 ZGC 使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上 收集过程 并发标记:标记阶段会更新染色指针中的 Marked0、Marked1 标志位 并发预备重分配: 根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set) 并发重分配(Concurrent Relocate):重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用 选择合适的垃圾收集器 如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点 如果是 SLA 应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点 如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的

2021 Apr 02 · 3 min

深入理解Java虚拟机::自动内存管理

自动内存管理 程序计数器 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器 每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址 如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined) Java 虚拟机栈 线程私有的 生命周期与线程相同 每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息 局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型、对象引用和 returnAddress 类型 StackOverflowError OutOfMemoryError 本地方法栈 虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务 StackOverflowError OutOfMemoryError Java 堆 Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建 此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存 逃逸分析,栈上分配,标量替换 垃圾收集器管理的内存区域 分代收集理论 所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区 TLAB 以提升对象分配时的效率 Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的 OutOfMemoryError 方法区 线程共享 存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据 可以选择不实现垃圾收集 内存回收目标主要是针对常量池的回收和对类型的卸载 运行时常量池 方法区的一部分 存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中 具备动态性 OutOfMemoryError 直接内存 不是虚拟机运行时数据区的一部分 在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作 不会受到 Java 堆大小的限制 OutOfMemoryError 对象的创建 当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程 对象所需内存的大小在类加载完成后便可完全确定 对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来 假设 Java 堆中内存是绝对规整的–指针碰撞 如果 Java 堆中的内存并不是规整的–空闲列表 选择哪种分配方式由 Java 堆是否规整决定 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定 指针碰撞 Serial、ParNew 等带压缩整理过程的收集器 空闲列表 使用 CMS 这种基于清除(Sweep)算法的收集器 在虚拟机中对象创建的线程安全 对分配内存空间的动作进行同步处理 内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定 内存分配完成之后虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值 对对象进行必要的设置 new 指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来 对象的内存布局 对象头(Header) 对象自身的运行时数据 哈希码(HashCode) GC 分代年龄 锁状态标志 线程持有的锁 偏向线程 ID 偏向时间戳 类型指针 指向它的类型元数据的指针 记录数组长度的数据 实例数据(Instance Data) 程序代码里面所定义的各种类型的字段内容 父类继承下来的 子类中定义的字段 对齐填充(Padding) 不是必然存在的 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍 对象的访问定位 使用句柄访问 Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息 使用直接指针访问 考虑如何放置访问类型数据的相关信息 reference 中存储的直接就是对象地址 速度更快,它节省了一次指针定位的时间开销

2021 Apr 01 · 1 min

深入理解Java虚拟机::走进 Java

走进 Java 发展史 1996.01.23 JDK 1.0 发布 JVM Applet AWT 1996.02.19 JDK 1.1 发布 JAR 文件格式 JDBC JavaBeans RMI 内部类 反射 1998.12.04 JDK 1.2 发布 J2SE J2EE J2ME EJB Java Plug-in Java IDL Swing JIT VM Classic VM HotSpot VM Exact VM strictfp 关键字 2000.05.08 JDK 1.3 发布 JNDI Java 2D API JavaSound 2002.02.13 JDK 1.4 发布 正则表达式 异常链 NIO 日志类 XML 解析器和 XSLT 转换器 2004.09.30 JDK 5 发布 自动装箱 范型 动态注解 枚举 可变长参数 遍历循环 JMM JUC 2006.12.11 JDK 6 发布 动态语言支持 编译期注解处理器 微型 HTTP 服务器 API 锁和同步,垃圾回收,类加载改进 2009.02.19 JDK 7 发布 支持 Mac OS G1 收集器 加强对非 Java 语言的调用支持 可并行的类加载架构 2014.03.18 JDK 8 发布 Lambda 支持 Nashorn JavaScript 引擎 新的时间日期 API 彻底移除 HotSpot 的永久代 2017.09.21 JDK 9 发布 Jigsaw 模块化 JShell JLink HTTP 2 2018.03.20 JDK 10 发布 本地类型推断增强 整合 JDK 代码仓库 统一的垃圾回收接口 应用程序类数据共享 2018.09.25 JDK 11 发布 ZGC 授权许可调整 Epsilon:低开销垃圾回收器 标准 HTTP Client 升级 基于嵌套的访问控制 简化启动单个源代码文件的方法 用于 Lambda 参数的局部变量语法 低开销的 Heap Profiling 支持 TLS 1.3 协议 飞行记录器 2019.03.20 JDK 12 发布 Switch 表达式 Shenandoah 垃圾回收器 2019.08.17 JDK 13 发布 ZGC 增强 更新 Socket 实现 Switch 表达式更新 文本块 2020.03.17 JDK 14 发布 instanceof 模式匹配 G1 的 NUMA 可识别内存分配 改进 NullPointerExceptions 提示信息 Record 类型 Switch 表达式 删除 CMS 垃圾回收器 2020.09.15 JDK 15 发布 Edwards-Curve 数字签名算法 封闭类 禁用、弃用偏向锁 Java 虚拟机家族 Sun Classic/Exact VM HotSpot VM Mobile/Embedded VM BEA JRockit/IBM J9 VM BEA Liquid VM/Azul VM Apache Harmony/Google Android Dalvik VM Microsoft JVM 及其他

2021 Mar 31 · 2 min

心理学::被讨厌的勇气

被讨厌的勇气 我们的不幸是谁的错 如果我们一直依赖原因论,就会永远止步不前。 任何经历本身并不是成功或者失败的原因。我们并非因为自身经历中的刺激——所谓的心理创伤——而痛苦,事实上我们会从经历中发现符合自己目的的因素。决定我们自身的不是过去的经历,而是我们自己赋予经历的意义。 我们大家都是在为了某种“目的”而活着。这就是目的论。 所谓愤怒其实只是可放可收的一种“手段”而已。 答案不应该是从别人那里得到,而应该是自己亲自找出来。 重要的不是被给予了什么,而是如何去利用被给予的东西。 无视现实的是你。一味执著于“被给予了什么”,现实就会改变?我们不是可以更换的机械。我们需要的不是更换而是更新。 比如现在你感觉不到幸福。有时还会觉得活得很痛苦,甚至想要变成别人。但是,现在的你之所以不幸正是因为你自己亲手选择了“不幸”,而不是因为生来就不幸。 行为之恶的确有很多。但无论什么样的犯罪者,都没有因为纯粹想要作恶而去干坏事的,所有的犯罪者都有其犯罪的内在的“相应理由”。假设有人因为金钱纠纷而杀了人。即使如此,对其本人来说也是有“相应理由”的行为,换句话说就是“善”的行动。当然,这不是指道德意义上的善,而是指“利己”这一意义上的善。 你在人生的某个阶段里选择了“不幸”。这既不是因为你生在了不幸的环境中,也不是因为你陷入了不幸的境地中,而是因为你认为“不幸”对你自身而言是一种“善”。 人时常在选择着自己的生活方式,即使像现在这样促膝而谈的瞬间也在进行着选择。你把自己说成不幸的人,还说想要马上改变,甚至说想要变成别人。尽管如此还是没能改变,这是为什么呢?那是因为你在不断地下着不改变自己生活方式的决心。 如果选择新的生活方式,那就既不知道新的自己会遇到什么问题,也不知道应该如何应对眼前的事情。未来难以预测,生活就会充满不安,也可能有更加痛苦、更加不幸的生活在等着自己。也就是说,即使人们有各种不满,但还是认为保持现状更加轻松、更能安心。 阿德勒心理学就是勇气心理学。你之所以不幸并不是因为过去或者环境,更不是因为能力不足,你只不过是缺乏“勇气”,可以说是缺乏“获得幸福的勇气”。 的确。你现在首先应该做的是什么呢?那就是要有“摈弃现在的生活方式”的决心。 无论之前的人生发生过什么,都对今后的人生如何度过没有影响。”决定自己人生的是活在“此时此刻”的你自己。 一切烦恼都来自人际关系 你为什么讨厌自己呢?为什么只盯着缺点就是不肯去喜欢自己呢?那是因为你太害怕被他人讨厌、害怕在人际关系中受伤。 那么,如何实现这种目的呢?答案很简单。只要变成一个只看自己的缺点、极其厌恶自我、尽量不涉入人际关系的人就可以了。如此一来,只要躲在自己的壳里就可以不与任何人发生关联,而且万一遭到别人的拒绝,还可以以此为理由来安慰自己。心里就会想:因为我有这样的缺点才会遭人拒绝,只要我没有这个缺点也会很讨人喜欢。 但是,请你不要忘记,在人际关系中根本不可能不受伤。只要涉入人际关系就会或大或小地受伤,也会伤害别人。 一切烦恼都是人际关系的烦恼 困扰我们的自卑感不是“客观性的事实”而是“主观性的解释”? 我们无法改变客观事实,但可以任意改变主观解释。并且,我们都活在主观世界中。 也就是说,价值必须建立在社会意义之上。即使 1 美元纸币所承载的价值是一种常识(共通感觉),那它也不是客观意义上的价值。如果从印刷成本考虑的话,它根本不等于 1 美元。 自卑感本身并不是坏事。这一点你能够理解吧?就像阿德勒说过的那样,自卑感也可以成为促成努力和进步的契机。例如,虽然对学历抱有自卑感,但若是正因为如此,才下定“我学历低所以更要付出加倍的努力”之类的决心,那反而成了好事。而另一方面,自卑情结是指把自己的自卑感当作某种借口使用的状态。具体就像“我因为学历低所以无法成功”或者“我因为长得不漂亮所以结不了婚”之类的想法。像这样在日常生活中大肆宣扬“因为有 A 所以才做不到 B”这样的理论,这已经超出了自卑感的范畴,它是一种自卑情结。 简单地说就是害怕向前迈进或者是不想真正地努力。不愿意为了改变自我而牺牲目前所享受的乐趣——比如玩乐或休闲时间。也就是拿不出改变生活方式的“勇气”,即使有些不满或者不自由,也还是更愿意维持现状。 如果真正地拥有自信,就不会自大。正因为有强烈的自卑感才会骄傲自大,那其实是想要故意炫耀自己很优秀。担心如果不那么做的话,就会得不到周围的认可。这完全是一种优越情结。这是一种通过把自卑感尖锐化来实现异常优越感的模式。具体就是指夸耀不幸。 以自己的不幸为武器来支配对方。通过诉说自己如何不幸、如何痛苦来让周围的人——比如家人或朋友——担心或束缚支配其言行。刚开始提到的那些闭门不出者就常常沉浸在以不幸为武器的优越感中。阿德勒甚至指出:“在我们的文化中,弱势其实非常强大而且具有特权。 不与任何人竞争,只要自己不断前进即可 健全的自卑感不是来自与别人的比较,而是来自与“理想的自己”的比较。 好吧,我们都不一样。性别、年龄、知识、经验、外貌,没有完全一样的人。我们应该积极地看待自己与别人的差异。但是,我们“虽然不同但是平等”。 不是。无论是走在前面还是走在后面都没有关系,我们都走在一个并不存在纵轴的水平面上,我们不断向前迈进并不是为了与谁竞争。价值在于不断超越自我。 这与竞争有关。请你记住。如果在人际关系中存在“竞争”,那人就不可能摆脱人际关系带来的烦恼,也就不可能摆脱不幸。 竞争的可怕之处就在于此。即便不是败者、即便一直立于不败之地,处于竞争之中的人也会一刻不得安心、不想成为败者。而为了不成为败者就必须一直获胜、不能相信他人。之所以有很多人虽然取得了社会性的成功,但却感觉不到幸福,就是因为他们活在竞争之中。因为他们眼中的世界是敌人遍布的危险所在。 把他人的幸福看作“我的失败”,所以才无法给予祝福。 如果能够体会到“人人都是我的伙伴”,那么对世界的看法也会截然不同。不再把世界当成危险的所在,也不再活在不必要的猜忌之中,你眼中的世界就会成为一个安全舒适的地方。人际关系的烦恼也会大大减少。 这种情况下,对方的目的是什么呢?是纯粹想要讨论政治吗?不是。对方只是想要责难挑衅你,通过权力之争来达到让不顺眼的你屈服的目的。这个时候你如果发怒的话,那就是正中其下怀,关系会急剧转入权力之争。所以,我们不能上任何挑衅的当。 不是不能发怒,而是“没必要依赖发怒这一工具”。 那就是无论认为自己多么正确,也不要以此为理由去责难对方。这是很多人都容易陷落进去的人际关系圈套。 原本主张的对错与胜负毫无关系。如果你认为自己正确的话,那么无论对方持什么意见都应该无所谓。但是,很多人都会陷入权力之争,试图让对方屈服。正因为如此,才会认为“承认自己的错误”就等于“承认失败”。 承认错误、赔礼道歉、退出权力之争,这些都不是“失败”。 那么你为什么把别人看成是“敌人”而不能认为是“伙伴”呢?那是因为勇气受挫的你在逃避“人生的课题”。 行为方面的目标有以下两点: 自立。 与社会和谐共处。 而且,支撑这种行为的心理方面的目标也有以下两点: “我有能力”的意识。 “人人都是我的伙伴”的意识。 阿德勒心理学不是改变他人的心理学,而是追求自我改变的心理学。不能等着别人发生变化,也不要等着状况有所改变,而是由你自己勇敢迈出第一步。 当人能够感觉到“与这个人在一起可以无拘无束”的时候,才能够体会到爱。既没有自卑感也不必炫耀优越性,能够保持一种平静而自然的状态。真正的爱应该是这样的。 现阶段能说的就是不能够逃避。无论多么困难的关系都不可以选择逃避,必须勇敢去面对。即使最终发展成用剪刀剪断,也要首先选择面对。最不可取的就是在“这样”的状态下止步不前。 人就是这么任性而自私的生物,一旦产生这种想法,无论怎样都能发现对方的缺点。即使对方是圣人君子一样的人物,也能够轻而易举地找到对方值得讨厌的理由。正因为如此,世界才随时可能变成危险的所在,人们也就有可能把所有他人都看成“敌人”。 让干涉你生活的人见鬼去 阿德勒心理学否定寻求他人的认可。 你不是为了满足别人的期待而活着,我也不是为了满足别人的期待而活着。我们没必要去满足别人的期待。 如果一味寻求别人的认可、在意别人的评价,那最终就会活在别人的人生中。 基本上,一切人际关系矛盾都起因于对别人的课题妄加干涉或者自己的课题被别人妄加干涉。 辨别究竟是谁的课题的方法非常简单,只需要考虑一下“某种选择所带来的结果最终要由谁来承担?” 这一点需要注意。阿德勒心理学并不是推崇放任主义。放任是一种不知道也不想知道孩子在做什么的态度。而阿德勒心理学的主张不是如此,而是在了解孩子干什么的基础上对其加以守护。如果就学习而言,告诉孩子这是他自己的课题,在他想学习的时候父母要随时准备给予帮助,但绝不对孩子的课题妄加干涉。在孩子没有向你求助的时候不可以指手画脚。 接受心理咨询辅导之后,被辅导者下什么样的决心、是否改变生活方式,这都是被辅导者本人的课题,辅导顾问不能干涉。 可以把马带到水边,但不能强迫其喝水 能够改变自己的只有自己。 ...

2021 Mar 28 · 1 min

数据密集型应用系统设计::派生数据

派生数据 记录系统 一个记录系统也被称为真实数据系统,拥有数据的权威版本 如果另一个系统与记录系统之间存在任何差异,那么以记录系统中的数据值为准 派生数据系统 派生数据系统中的数据则是从另一个系统中获取已有数据并以某种方式进行转换或处理的结果 如果派生数据丢失,用户可以从原始数据源进行重建 例如缓存 批处理系统 在线服务 服务等待客户请求或指令的到达。当收到请求或指令时,服务试图尽可能快地处理它,并发回一个响应。 响应时间通常是服务性能的主要衡量指标,而可用性同样非常重要 批处理系统 批处理系统接收大量的输入数据,运行一个作业来处理数据,并产生输出数据 批处理作业的主要性能衡量标准通常是吞吐量 流处理系统 流处理介于在线与离线/批处理之间(所以有时称为近实时或近线处理)。与批处理系统类似,流处理系统处理输入并产生输出 流式作业在事件发生后不久即可对事件进行处理,而批处理作业则使用固定的一组输入数据进行操作。这种差异使得流处理系统比批处理系统具有更低的延迟。 使用 UNIX 工具进行批处理 使用 awk, sed, grep, sort , uniq 和 xargs 的组合 ,可以在几分钟内完成许多数据分析任务 UNIX 设计哲学 每个程序做好一件事。如果要做新的工作,则建立一个全新的程序,而不是通过增加新“特征”使旧程序变得更加复杂。 期待每个程序的输出成为另一个尚未确定的程序的输入。不要将输出与无关信息混淆在一起。避免使用严格的表格状或二进制输入格式。不要使用交互式输入 尽早尝试设计和构建软件,甚至是操作系统,最好在几周内完成。需要扔掉那些笨拙的部分时不要犹豫,并立即进行重建 优先使用工具来减轻编程任务,即使你不得不额外花费时间去构建工具,并且预期在使用完成后会将其中一些工具扔掉 统一接口 如果希望某个程序的输出成为另一个程序的输入,也就意味着这些程序必须使用相同的数据格式,换句话说,需要兼容的接口,在 UNIX 中,这个接口就是文件(更准确地出,是文件描述符) 逻辑与布线分离 UNIX 工具的另一个特点是使用标准输入(stdin)和标准输出(stdout) 这允许 shell 用户以任何他们想要的方式连接输入和输出:程序并不知道也不关心输入来自哪里以及输出到哪里。 将输入/输出的布线连接与程序逻辑分开,可以更容易地将小工具组合成更大的系统 透明与测试 UNIX 命令的输入文件通常被视为是不可变的。这意味着可以随意运行命令,尝试各种命令行选项,而不会损坏输入文件。 可以在任何时候结束流水线,将输出管道输送到 less ,然后查看它是否具有预期的形式。这种检查能力对调试非常有用。 可以将流水线某个阶段的输出写入文件,并将该文件用作下一阶段的输入。这使得用户可以重新启动后面的阶段,而无需重新运行整个流水线。 MapReduce 与分布式文件系统 MapReduce 有点像分布在数千台机器上的 UNIX 工具 不修改输入,无副作用,在分布式文件系统上读写文件 分布式文件系统 GFS HDFS Amazon S3 Azure Blob OpenStack Swift HDFS 包含一个在每台机器上运行的守护进程,并会开放一个网络服务以允许其他节点访问存储在该机器上的文件 容错 多个机器上的相同数据的多个副本 纠错码方案 MapReduce 作业执行 MapReduce 是一个编程框架,可以使用它编写代码来处理 HDFS 等分布式文件系统中的大型数据集 处理模式 读取一组输入文件,并将其分解成记录 调用 mapper 函数从每个输入记录中提取一个键值对 按关键字将所有的键值对排序 调用 reducer 函数遍历排序后的键值对 Mapper 每个输入记录都会调用一次 mapper 程序,其任务是从输入记录中提取关键字和值。对于每个输入,它可以生成任意数量的健值对(包括空记录)。它不会保留从一个输入记录到下一个记录的任何状态,因此每个记录都是独立处理的。 Reducer MapReduce 框架使用由 mapper 生成的键值对,收集属于同一个关键字的所有值,并使用迭代器调用 reducer 以使用该值的集合。Reducer 可以生成输出记录 MapReduce 的分布式执行 作业的输入通常是 HDFS 中的一个目录,且输入目录中的每个文件或文件块都被视为一个单独的分区,可以由一个单独的 map 任务来处理 让靠近数据的机器就近执行 mapper 任务,减少网络传输的负载,提高了访问局部性 map 任务的数量与 reduce 任务的数量不需要一致 为了使相同值的 mapper 输出交给同一个 reduce 处理,采用了关键字哈希分区 键值对必须排序。map 任务基于关键字哈希,按照 reducer 对输出进行分块。每个分区都被写入 mapper 程序所在文件磁盘上的已排序文件。 当 mapper 完成文件输出后,MP 调度器通知 reducer 从 mapper 中获取输出文件。reduce 从 mapper 中获取文件后把他们合并在一起,同时保持数据的排序。 Reduce 端的 join 与分组 在批处理的背景下讨论 join 时,我们主要是解决数据集内存在关联的所有事件 join 最简单的实现是遍历所有记录,并在远程服务器中查找对应的记录。 更好的方法是获取用户数据库的副本,并将其放入与用户活动事件日志相同的分布式文件系统。然后,可以将用户数据库放在 HDFS 中的一组文件中,并将用户活动记录放在另一组文件中,使用 MapReduce 将所有相关记录集中到一起,从而有效地处理它们。 排序-合并 join mapper 和排序过程确保将执行特定用户 ID join 操作的所有必要数据都放在一起,这样就只需要一次 reducer 调用。因为所有需要的数据已经预先排列好,所以 reducer 是一段相当简单的单线程代码,以高吞吐量和低内存开销来处理记录。 分组 在 mapper 阶段,使其生成的键值对使用所需的分组关键字。然后,分区和排序过程将相同 reducer 中所有具有相同关键字的记录集合在一起 处理数据倾斜 如果 join 输入中存在热键,则可以使用算法进行补偿。在真正开始执行 join 时,mapper 将任何与热键有关的记录发送到随机选择的若干个 reducer 中的一个,对于 join 的其他输入,与热键相关的记录需要被复制到所有处理该关键字的 reducer 中,这种技术将处理热键的工作分散到多个 reducer 上,可以更好地实现并行处理,代价是不得不将 join 的其他输入复制到多个 reducer 使用热键对记录进行分组并汇总时,可以分两个阶段进行分组。第一个 MapReduce 阶段将记录随机发送到 reducer,以便每个 reducer 对热键的记录子集执行分组,并为每个键输出更紧凑的聚合值。然后第二个 MapReduce 作业将来自所有第一阶段 reducer 的值合并为每个键的单一值 Map 端的 join 操作 广播哈希 join 实现 map 端 join 的最简单方住特别适合大数据集与小数据集 join,尤其是小数据集能够全部加载到每个 mapper 的内存中 当 mapper 程序执行时,它可以首先将用户数据库从分布式文件系统读取到内存的哈希表中。然后,mapper 程序扫描用户活动事件,并简单地查找哈希表中每个事件的用户 ID Map 任务依然可以有多个: 大数据集的每个文件块对应一个 mapper。每个 mapper 还负责将小数据集全部加载到内存中。 也可以存在磁盘中,由于缓存和索引的原因,和内存查找差不多快。 分区哈希 join 如果以相同方式对 map 端 join 的输入进行分区,则哈希 join 方法可以独立作用于每个分区 批处理工作流的输出 生成搜索索引 批处理输出键值 批处理输出的哲学 如果在代码中引入了漏洞,输出错误或者损坏,那么可以简单地回读到先前版本,然后重新运行该作业,将再次生成正确的输出; 或者更简单的办法是将旧的输出保存在不同的目录中,然后切换回原来的目录 与发生错误即意味着不可挽回的损害相比 ,易于回滚的特性更有利于快速开发新功能。这种使不可逆性最小化的原则对于敏捷开发是有益的 如果 map 或 reduce 任务失败, MapReduce 框架会自动重新安排作业并在同一个输入上再次运行,失败任务的输出则被 MapReduce 框架丢弃 对比 Hadoop 与分布式数据库 存储多样性 分布式文件系统中的文件只是字节序列,可以使用任何数据模型和编码来编写 来自事务处理 系统的数据以某种原始形式转储到分布式文件系统中,然后编写 MapReduce 作业进行数据清理,将其转换为关系表单,并将其导入 MPP 数据仓库以进行分析。数据建模仍然会发生,但它位于一个单独步骤中,与数据收集是分离的。由于分布式文件系统支持以任何格式编码的数据,所以这种解相是可行的。 处理模型多样性 并非所有类型的处理都可以合理地表达为 SQL 查询 由于 Hadoop 平台的开放性,可以在上面实施更多的处理模型 针对频繁故障的设计 MapReduce 可以容忍 map 或者 reduce 任务失败,单个失败可以重试。 MapReduce 容忍任意失败是为了更好的利用集群资源,允许高优先级任务抢占资源 超越 MapReduce MapReduce 很强大,但依然有些问题 对于某些类型的任务,其他工具可能要快几个数量级 中间状态实体化 MapReduce 会把中间结果写入文件,这个过程称为实体化 UNIX 管道不存在实体化,输出可以立即成为下一个的输入 几个问题 输出不会立马被利用,任务耗时会比预计的久 mapper 冗余,它们只是读取刚刚由 reducer 写入的同一个文件,并为下一个分区和排序阶段做准备。在许多情况下,mapper 代码可能是之前 reducer 的一部分:如果 reducer 的输出被分区和排序的方式与 mapper 输出相同,那么不同阶段的 reducer 可以直接链接在一起,而不需要与 mapper 阶段交错。 把中间状态文件存储在分布式系统中意味着这些文件被复制都多个节点了,对于这样的临时数据来说通常是大材小用了 数据流引擎 为了解决 MapReduce 的这些问题,开发了用于分布式批处理的新的执行引擎 Spark,Tez,Flink 它们把整个工作流作为一个作业来处理,而不是把它分解成独立的子作业 通过若干个处理阶段明确地建模数据流,所以这些系统被称为数据流引擎。像 MapReduce 一样,它们通过反复调用用户定义的函数来在单个线程上一次处理一条记录。它们通过对输入进行分区来并行工作,并将一个功能的输出复制到网络上,成为另一个功能的输入 。 流处理引擎可以以更加灵活的方式组装各种函数 对比 MapReduce 模型的几个优点 排序等计算代价昂贵的任务只在实际需要的地方进行,而不是在每个 map 和 reduce 阶段之间默认发生 没有不必要的 map 任务,因为 mapper 所做的工作通常可以合并到前面的 reduce 运算符中 由于工作流中的所有 join 和数据依赖性都是明确声明的,因此调度器知道哪些数据在哪里是必需的,因此它可以进行本地优化 将运算符之间的中间状态保存在内存中或写入本地磁盘通常就足够了 运算符可以在输入准备就绪后立即开始执行,在下一个开始之前不需要等待前个阶段全部完成。 现有的 Java 虚拟机进程可以被重用来运行新的运算符,从而减少启动开销。 容错 将中间状态完全实体化到分布式文件系统的一个优点是持久化,这使得在 MapReduce 中实现容错变得相当容易 Spark, Flink 和 Tez 避免将中间状态写入 HDFS,所以它们采用不同的方法来容忍错误: 如果机器发生故障,并且该机器上的中间状态、丢失,则利用其他可用的数据重新计算 为了实现重新计算,框架必须追踪给定数据是如何计算的,使用了哪个输入分区,以及应用了哪个运算符。 关于实体化的讨论 数据流对 MapReduce 的改进是,不需要自己将所有中间状态写入文件系统。 图与迭代处理 在批处理环境中查看图也很有趣,其目标是在整个图上执行某种离线处理或分析。这种需求经常出现在机器学习应用程序或排名系统中 Pregel 处理模型 作为对图数据的批处理优化,计算的批量同步并行模型 一个顶点可以“发送消息”到另一个顶点,通常这些消息沿着图的边被发送。 顶点状态和顶点之间的消息具有容错性和持久性,并且通信以固定的方式进行:每一轮迭代中,框架会将前一次迭代中的所有消息都发送出去。 Actor model 通常没有这样的时序保证。 容错 由于 Pregel 集型保证在一次迭代中发送的所有消息都会在下一次迭代中被发送,所以先前的迭代必须全部完成,而且所有的消息必须在下一次迭代 开始之前复制到网络中。 即使底层网络可能会丢弃、重复或任意延迟消息,但 Pregel 的实现可以保证在后续迭代中消息在目标顶点只会被处理一次。像 MapReduce 一样,该框架透明地从故障中恢复,以简化 Pregel 顶层算陆的编程模型。 这种容错方式是通过在迭代结束时定期快照所有顶点的状态来实现的,即将其全部状态写入持久存储。如果某个节点发生故障并且其内存中状态丢失,则最简单的解决方也是将整个图计算回攘到上一个检查点,然后重新开始计算。如果算法是确定性的, 并且记录了消息,那么也可以选择性地只恢复丢失的分区 并行执行 顶点不需要知道它运行在哪台物理机器上。当它发送消息到其他顶点时,只需要将消息发送至一个顶点 ID。框架对图进行分区,即确定哪个顶点运行在哪个机器上,以及如何通过网络路由消息,以便它们都能到达正确的位置。 高级 API 与语言 由于手工编写 MapReduc 巳作业太过耗时费力,因此 Hive、Pig、Cascading 和 Crunch 等高级语言和 API 变得非常流行。随着 Tez 的出现, 这些高级语言还能够移植到新的数据流执行引擎,而无需重写作业代码。Spark 和 Flink 也包含他们自己的高级数据流 API 这些高级接口不仅使提高了系统利用率,而且提高了机器级别的作业执行效率。 转向声明式查询语言 通过将声明式特征与高级 API 结合,使查询优化器在执行期间可以利用这些优化方法,批处理框架看起来就更像 MPP 数据库了,井且能够实现性能相当。同时,通过具有运行任意代码和读取任意格式数据的可扩展性,它们依然保持了灵活性的优势。 不同领域的专业化 要实现可重用的通用构建模块 可重复使用统计和数值算法 流处理系统 我们将把事件流视为一种数据管理机制: 一种无界的、持续增量处理的方式 发送事件流 在流处理的上下文中,记录通常被称为事件 本质上是一回事:一个小的、独立的、不可变的对象,该对象包含某个时间点发生的事情的细节。每个事件通常包含一个时间戳,用于指示事件发生的墙上时间 案例 服务器的每一行日志 用户的下单或者浏览 格式 文本 JSON 二进制 生成一次,读取多次 在流系统中,相关的时间通常被组合成主题或流 传统数据库无法支持实时的消息通知 消息系统 向消费者通知新事件的常见方法是使用消息系统:生产者发送包含事件的消息,然后该消息被推送给一个或多个消费者 当生产者发送消息比消费者处理快 系统丢弃消息 将消息存储在队列中 激活背压,也称流量控制,组织生产者发送过多消息 如果节点崩溃或者离线,是否会有消息丢失 持久化需要写入磁盘,需要成本,在可以接收消息丢失的系统上,在同样的硬件上可以将获得更高的吞吐量和更低的延迟 是否接收消息丢失取决于应用程序 生产者与消费者之间的直接消息传递 UDP 组播广泛应用与金融行业,例如股票市场等低延迟场景 无代理的消息库,过 TCP 或 IP 多播实现发布/订阅消息传递。 使用不可靠的 UDP 消息传递 消费者在网络上公开服务,则生产者可以直接发出 HTTP 或 RPC 请求以将消息推送给消费者 缺点 有限容错,消息丢失 生产者和消费者都需要保持在线 消息代理 它作为服务器运行,生产者和消费者作为客户端连接到它。 生产者将消息写入代理,消费者通过从消息代理那里读取消息来接收消息 消息代理与数据库对比 而大多数消息代理在消息成功传递给消费者时就自动删除消息。这样的消息代理不适合长期的数据存储。 消息代理的工作集很小,如果消费慢了就会占用很多内存,整体吞吐降低 数据库支持二级索引和各种搜索数据的方式,消息代理采用订阅匹配特定模式的主题 数据库查询基于数据快照,消息代理不支持查询 多个消费者 负载均衡式 每一条消息都只被传递给其中一个消费者,所以消费者可以共享主题中处理消息的工作。代理可以任意分配消息给消费者。 扇出式 每条消息都被传递给所有的消费者。扇出允许几个独立的消费者各自“收听”相同的消息广播,而不会相互影响,流相当于多个读取相同输入文件的不同批处理作业 确认和重新传递 消费者随时可能崩溃,消费者消费完事件后需要显式告知消息代理,消息代理才会从队列中删除 消息代理重试时间导致事件发生顺序改变,如果消息之间存在因果关系会导致问题。 分区日志 将数据库的持久存储方居与消息传递的低延迟功能相结合 基于日志的消息存储 日志是磁盘上一个仅支持追加式修改记录的序列,我们可以使用相同的结构来实现消息代理:生产者通过将消息追加到日志的末尾来发消息,消费者通过依次读取日志来接收消息。如果消费者读到日志的末尾,它就开始等待新消息被追加的通知。 为了突破单个磁盘所能提供的带宽吞吐的上限,可以对日志进行分区。不同的节点负责不同的分区,使每个分区成为一个单独的日志,并且可以独立于其他分区读取和写入。然后可以将主题定义为一组分区,他们都携带相同类型的消息 在每个分区中,代理为每个消息分配一个单调递增的序列号或偏移量。这样的序列号是非常有意义,因为分区只能追加,所以分区内的消息是完全有序的。不同分区之间则没有顺序保证。 对比日志与传统消息系统 都支持扇出式消息传递 因为同一分区内的消息将被传递到同一节点,所以消费一个主题的节点数最多等于该主题中的日志分区数 如果单个消息处理缓慢,则会阻碍该分区中的后续消息的处理 消费者偏移量 顺序读取一个分区可以很容易地判断哪些消息已经被处理,代理不需要跟踪每条消息的确认,只需要定期记录消费者的偏移量 磁盘空间使用 如果持续不断地追加日志,磁盘空间最终将被耗尽。为了回收磁盘空间,日志实际上是被分割成段,并且不时地将旧段删除或归档保存。 如果一个消费者的速度慢到难以跟上消息产生的速度,并且远远落后以至于消费者偏移量指向了已经被删除的片段,那么消费者将会错过一些消息。实际上,日志实现了一个有限大小的缓冲区,当缓冲区变搞时,旧的消息就被丢弃,该缓冲区也被称为循环缓冲区或环形 缓冲区。由于该缓冲区在磁盘上,因此它可以非常大。 当消费者跟不上生产者时 基于日志的方法是一种缓冲形式,它具有比较大的缓冲区 当消费者明显落后消息时发出警报,让操作员有时间修复 重新处理信息 可以用旧的偏移量重新开启一个消费队列,并将输出写到不同的位置,以便重新处理最后一段时间的消息,通过改变处理代码可以多次重复此操作。 数据库与流 保持数据同步 多数据系统数据一致性 变更数据捕获 捕获数据库中的更改并不断将相同的更改应用于搜索索引。如果以相同顺序应用于更改日志,那么可以预期搜索索引中的数据与数据库中的数据匹配。搜索索引和任何其他派生的数据系统只是变更流的消费者 实现变更数据捕获 我们可以调用日志消费者的派生数据,变更数据捕获机制可以确保对记录系统所做的所有更改都反映在派生数据系统中,以便派生系统具有数据的准确副本,从本质上讲,变更数据捕获使得一个数据库成为主节点,井将其他变成从节点。由于基于日志的消息代理保留了消息的排序,因此它非常适合从原数据库传输更改事件 原始快照 如果有了数据库所有更改的日志,就可以通过 replay 日志来重建数据库的整个状态。构建新的全文索引需要整个数据库的完整副本,仅仅应用最近更改的日志还不够,因为它会丢失最近未更新的项目。因此,如果没有完整的日志历史记录,则需要从一致的快照开始,数据库的快照必须与更改日志中的已知位置或偏移量相对应,以便在快照处理完成后,知道在哪一点开始应用更改。 日志压缩 存储引擎定期查找具有相同 key 的日志记录,丢弃所有的重复项,井且只保留每个 key 的最新的更新。这个压缩和合并的过程是在后台运行的。 对变更流的 API 支持 数据库将关系数据模型中的输出流表示为表,该表支持事务插入元组,但不支持查询。输出流包含了向该特殊表提交写事务的元组日志,并严格按照事务提交顺序排序。外部消费者可以异步使用此日志并使用它来更新派生数据系统。 时间溯源 在变更捕获中,CDC 记录了操作发生的顺序 时间溯源中,应用程序逻辑基于写入事件日志的不可变事件构成。 从事件日志导出当前状态 使用事件溯源的应用程序需要记录事件的日志,井将其转换为适合向用户显示的状态 。这种转换可以使用任意的逻辑,但它应该是确定性的,以便可以再次运行它并从事件日志中派生相同的应用程序状态。 用于更新记录的 CDC 事件通常包含记录的全部新版本,因此 key 的当前值完全由该 key 的最近事件确定,井且日志压缩可以丢弃相同 key 之前的事件 使用事件溯源在更高的层次上对事件建模: 事件通常用来表达用户行为的意图,而不是一种对行为结果进行相应状态更新的机制 命令和事件 事件溯源的哲学是小心的区分事件和命令。当来自用户的请求第一次到达时,它最初是一个命令:此时它可能仍然会失败,例如因为违反了某些完整性条件。应用程序必须首先验证它是否可以执行该命令。如果验证成功并且命令被接受,它将变成一个持久且不可变的事件。 状态,流与不可变 事务日志记录了对数据库所做的所有更改。高速追加是更改日志的唯一方位。从这个角度来看,数据库的内容保存了日志中最新记录值的缓存。日志是事实。数据库是日志子集的缓存。该缓存子集恰好是来自日志的每个记录和索引值的最新值。 日志压缩则是链接日志与数据库区别的一种方式。它仅保留每条记录的最新版本,井丢弃被覆盖的版本。 不变事件的优势 恢复历史数据 记录历史操作 相同的事件日志中派生出多个视图 通过从不变事件日志中分离可变状态,可以从相同的事件日志派生出多个面向读取的表示方式 并发控制 事件捕获和变更数据捕获的最大缺点是事件日志的消费者通常是异步的,所以用户可能会写入日志,然后从日志派生的视图中读取,却发现这些写操作还没有反映在读取视图中 一种解决方案是同步执行读取视图的更新,并将事件追加到日志中。这需要一个事务来将写入操作合并到一个原子单元中,所以要么需要将事件日志和读取视图保存在同一个存储系统中,要么需要跨不同系统的分布式事务。 另一方面,从事件日志导出当前状态也简化了并发控制。对于多对象事务的大部分需求源自单个用户需要在不同地方改变数据的操作。通过事件溯源,可以设计一个事件,使其成为用户操作的独立描述。用户操作只需要在一个地方进行一次写操作,即将事件追加到日志中,这很容易使其原子化。 不变形的限制 隐私,彻底删除数据 流处理 三种处理 可以将事件中的数据写入数据库、缓存、搜索索引或者类似的存储系统,然后被其他客户端查询 可以通过某种方式将事件推送给用户 可以处理一个或多个输入流以产生一个或多个输出流。数据流可能会先经过由几个这样的处理阶段组成的流水线,最终在输出端结束 流处理的适用场景 监控目的的流应用 信用风控 金融交易监控 机器状态监控 军事情报系统报警 复杂事件处理 在流中搜索特定模式的事件 流分析 测量某种类型事件的速率 计算一段时间内某个值的波动平均值 将当前的统计数据与以前的时间间隔进行比较 维护物化视图 使用数据库更改流来保持派生数据系统与源数据库之间的同步,可以将这些示例视为一种维护物化视图的例子: 对某个数据集导出一个特定的试图以便高效查询,并在底层数据更改时自动更新该导出视图。 在流上搜索 需要基于一些复杂条件来搜索单个事件, 搜索流是把查询条件先保存下来,所有文档流过查询条件,筛选出结果。 消息传递和 RPC 流的时间问题 流处理系统经常需要和时间打交道,尤其是在用于分析目的时,这些分析通常使用时间窗口 事件延迟处理会引发流处理各种问题,需要有介入处理这种问题 混淆事件时间与处理时间会导致错误的结果,重启流处理系统倒是的事件积压,出现处理高峰 什么时候准备就绪 无法确定是否完全收到特定窗口内所有的事件 忽略滞后的事件,丢失大量数据时报警 发布一个更新,针对滞后事件的一个更新值。可能还需要收回以前的输出 用一个特殊值来触发窗口处理 你用谁的钟 为了调整不正确的设备时钟,一种是方法是记录三个时间戳 根据设备的时钟,记录事件发生的时间。 根据设备的时钟,记录将事件发送到服务器的时间。 根据服务器时钟,记录服务器收到事件的时间。 窗口类型 轮转窗口 翻滚窗口长度固定,每个事件都属于一个窗口 跳跃窗口 窗口长度固定,窗口之间有重叠以提供平滑过渡 滑动窗口 滑动窗口包含在彼此的某个间隔内发生的所有事件, 滑动窗口可以通过保留按时间排序的事件缓冲区井且在从窗口过期时移除旧事件来实现 会话窗口 与其他窗口类型不同,会话窗口没有固定的持续时间。相反,它是通过将同一用户在时间上紧密相关的所有事件分组在一起而定义的,一旦用户在一段时间内处于非活动状态,则窗口结束。会话分析是网站分析中常见的一种需求 流式 join 流和流 join 搜索事件和点击事件 流和表 join 流处理提前加载表的内容,在流处理时匹配相关 ID 表和表 join 流视图和流视图的 join,每当视图更新刷新 join 结果 join 的时间依赖性 不同流和分区之间的事件,顺序是如何确定的 通常的做法是,当数据发生变化后赋予一个新的关联 ID 流处理的容错 批处理容错方法可以确保批处理作业的输出与没有出错时的最终结果相同 微批处理和校验点 将流分解成多个小块,并像小型批处理一样处理每个块。这种方法被称为微批处理 Apache Flink 中使用了该方法的一个变体,它定期生成状态滚动检查点并将其写入持久化存储。如果流操作发生崩溃,它可以从最近的检查点重新启动,并丢弃在上一个检查点和崩溃之间生成的所有输出 重新审视原子提交 在出现故障时,为了看起来实现恰好处理了一次,我们需要确保当且仅当处理成功时,所有输出和副作用才会生效. 幕等性 容等操作是可以多次执行的操作,并且它与只执行一次操作具有相同的效果 故障后重建状态 任何需要状态的流处理,比如基于窗口的聚合以及表和索引的 join 操作,都必须确保在故障发生后状态可以恢复 一种选择是将状态保存在远程存储中井采取复制,然而为每个消息去查询远程数据库可能会很慢。另一种方也是将状态在本地保存,并定期进行复制。之后,当流处理器从故障中恢复时,新任务可以读取副本的状态、井且在不丢失数据的情况下恢复处理。 在某些情况下,甚至可能不需要复制状态,而是从输入流开始重建 数据系统的未来 每一个软件,即使是所谓的“通用”数据库,也都是针对特定的使用模式而设计的, 第一个挑战就是弄清楚软件产品与他们适合运行环境之间的对应关系 在复杂的应用程序中,数据通常以多种不同的方式被使用。不太可能存在适用于所有不同环境的软件,因此你不可避免地要将几个不同的软件组合在一起,以提供应用程序的功能性。 数据集成 采用派生数据来组合工具 许多应用程序需要结合两种或以上不同的工具来满足所有需求。 数据集成的需求通常只有在缩小井考虑整个组织框架内数据流时才会变得更加凸显 为何需要数据流 通过单个系统来决定所有输入的写入顺序,那么以相同的顺序处理写操作就可以更容易地派生出数据的其他表示形式 无论是使用变更数据捕获还是事件获取日志,都不如简化总体顺序的原则重要 根据事件日志来更新一个派生数据系统通常会比较好实现,并且可以实现确定性和幂等性 派生数据与分布式事务 分布式事务通过使用锁机制进行互斥来决定写操作的顺序 CDC 和事件源使用日志进行排序 分布式事务使用原子提交来确保更改只生效一次 基于日志的系统通常基于确定性重试和幕等性 事务系统通常提供线性化,保证读自己的写一致性 派生数据系统通常是异步更新的,所以默认情况下它们无法提供类似级别保证。 作者认为基于日志的派生数据是集成不同数据系统的最有前途的方法 全局的局限 完全有序的日志需要一个主节点来决定排序,随着系统变大,越来越复杂时,瓶颈就开始出现了 事件吞吐量大于单台的可处理上限时,需要分区到多个节点,不同分区之间的事件顺序难以保证 如果服务器在不在的数据中心,数据同步效率效率低,通常每个数据中心都有自己的主节点,两个不同数据中心的事件顺序不确定。 无状态的微服务之间不共享状态,两个事件来自不同的服务时,这些事件没有清楚的顺序。 网络延迟甚至离线导致的数据不一致问题。 设计突破单节点吞吐量甚至在广域地理环境分布的共识算能仍然是一个有待研究的开放性问题 排序事件以捕获因果关系 逻辑时间戳可以在无协调者情况下提供的全序关系,所以当全序关系广播不可行时可以用得上,但是,它们仍然需要接收者 去处理那些乱序事件,井且需要额外的元数据 如果可以记录一条事件来标记用户在做决定以前所看到系统状态,并给该事件一个唯一的标识符,那么任何后续的事件都可以通过引用该事件标识符来记录因果关系 冲突解决算法,可以处理异常顺序的事件。 批处理和流处理集成 数据整合的目标是确保数据在所有正确的地方以正确的形式结束 批处理的数据是已知的有限大小 流处理运行在无界的数据集上 一种类型的处理可以通过另一种类型来模拟,尽管性能特征有所不同。 保持派生状态 批处理 倡导确定性 纯函数操作,输出仅依赖输入 输出不可变 追加式输出结果 流处理 除了批处理的特征 扩展了操作来支持可管理的,容错的状态 拥有良好定义的输入和输出的确定性函数原理上不仅有利用容错,还简化了组织中数据流的推理 从数据管道的角度来看,对于从一个事物派生出另一个事物,通过功能应用程序代码推动一个系统中的状态更改以及将这种效果应用到派生系统,都是有帮助的。 为应用程序演化而重新处理数据 为维护系统提供了一个良好的机制,平滑支持新功能以及多变的需求 通过重新处理,可以将数据集重组为一个完全不同的模型,以便更好地满足新要求。 派生视图可以逐步演变,知道所有用户迁移到新视图,如果有风险,总有一个工作系统可以回退 Lambda 架构 Lambda 体系结构的核心思想是进来的数据以不可变事件形式追加写到不断增长的数据集,类似于事件源。基于这些总事件,可以派生出读优化的视图。 统一批处理和流处理 支持以相同的处理引擎来处理最新事件和处理历史回放事件。 支持只处理一次语义 支持依据事件发生时间而不是处理时间进行窗口化 分拆数据库 编排多种数据存储技术 创建一个索引 数据库必须扫描表的一致性快照,挑选出所有被索引的字段值,对它们进行排序,然后得到索引。接下来,必须处理从一致性快照创建以来所累计的写入操作,完成后,只要有事务写入表中,数据库就必须持续保持索引处于最新状态。 索引是现有数据的一个视图 元数据库 联合数据库:统一端读 可以为各种各样的底层存储引擎和处理方法提供一个统一的查询接口:一种称为联合数据库或聚合存储的方法 分离式数据库:统一写端 在构建跨多个存储系统的数据库时,我们同样需要确保所有数据更改都会体现在所有正确的位置上,即使中间发生了某些故障。多个存储系统可以可靠地连接在一起 分离式如何工作 基于日志的集成的一大优势是各个组件之间的松耦合,这体现在两个方面 在系统级别,异步事件流使整个系统在应对各个组件的中断或性能下降时表现更加稳健,日志可以慢慢消费且不会丢失。 在人员角度看,分离式数据系统使得不同的团队可以独立的开发、改进和维护不同的软件组件和服务。专业化使得每个团队都可以专注于做好一件事情,且与其他系统维护清晰明确的接口,事件日志提供了一个足够强大的接口,不但能捕获相当强的一致性,同时也普遍适用于几乎任何类型的数据。 分离式与集成式系统 目前形式的数据库不会被取代 维护流处理器中的状态仍然需要数据库 专门的查询引擎对于特定的工作负载仍然很重要 运行多个不同的基础架构所带来的复杂性可能确是一个问题 不同的学习曲线 不同的配置 不同的操作习惯 单个集成的软件产品有可能确实在其针对的负载上表现更好,性能更可预测 分离的目标是让你可以将多个不同的数据库组合起来,以便在更广泛的工作负载范围内实现比单一软件更好的性能 当没有单一的软件能满足所有需求时,分离和组合的优势才会显现出来 围绕数据流设计应用系统 应用程序代码作为派生函数 当某个数据集从另一个数据集派生而来时,它一定会经历某种转换函数 二级索引是一种派生的数据集,它具有一个简单的转换函数:对于主表中的每一行或者一个文档,挑选那些索引到的列或者字段值,并且按照值进行排序 通过各种自然语言处理函数创建全文搜索索引,然后构建用于高效查找的数据结构 在机器学习系统中,可以考虑通过应用各种特征提取和统计分析功能从训练数据中导出模型。 缓存通常包含那些即将显式在用户界面的聚合数据 应用程序代码与状态分离 作者认为系统的某部分专注于持久性数据存储,同时有另外一部分专门负责运行应用程序代码是有道理的。这两部会有交互,但是各自仍保持独立运行。 数据库充当一种可以通过网络同步访问的可变共享变量。应用程序可以读取或更新变量,数据库负责持久性,提供一些井发控制和容错功能。 数据流: 状态变化和应用程序代码之间的相互影晌 当维护报生数据时,状态更改的顺序通常很重要,如果从事件日志中派生出了多个视图,每个试图都需要按照相同的顺序来处理这些事件,以使它们互相保持一致。 容错性是派生数据的关键: 丢失哪怕单个消息都会导致派生数据集永远无法与数据源同步。消息传递和派生状态更新都必须可靠。 流式处理与服务 面向服务的结构优于单体应用程序之处在于松相合所带来的组织伸缩性: 不同的团队可以在不同的服务上工作,这减少了团队之间的协调工作 数据流系统与微服务理念有很多相似的特征。但是,底层的通信机制差异很大:前者是单向、异步的消息流,而不是同步的请求/响应交互 最快和最可靠的网络请求就是根本没有网络请求 订阅变化的流,而不是在需要时去查询状态,使我们更接近类似电子表格那样的计算模型: 当某些数据发生更改时,依赖于此的所有派生数据都可以快速更新 观察派生状态 写路径和读路径涵盖了数据的整个过程,从数据收集到数据使用 写路径可以看作是预计算的一部分,即一旦数据进入,即刻完成,无论是否有人要求访问它。 过程中的读路径则只有当明确有人要求访问时才会发生。 实体化视图与缓存 全文索引:写路径构建了索引,读路径不需要扫描全部文档 缓存:对常见查询进行预计算,使这部分查询可以快速响应,其他的依然依靠索引,当添加新的数据时,视图也要随之更新 它们主要是调整读、写路径之间的边界。通过预先计算结果,写路径上承担了更多的工作,而读路径则可以简化加速 有状态,可离线客户端 SPA 应用支持很多有状态的功能,移动 app 也可以在本地保存很多状态,很多交互不需要和服务器通信。 我们可以将设备上的状态视为服务器上的状态缓存。屏幕上的呈现是一种客户端对象模型的实体化视图;而客户端的对象模型则是远程数据中心在本地的状态副本 状态更改推送至客户端 更新的 HTTP 协议支持从服务端推送事件到客户端,从而缩小两者之间状态的滞后程度 端到端的事件流 状态变化可以通过端到端的写路径流动:某个设备上交互行为触发了状态变化,通过事件日志、派生数据系统和流式处理等,一直到另一台设备上用户观察到状态。这些状态变化传播的延迟可以做到很低的水平,例如端到端只需一秒。 为了将写路径扩展到最终用户,我们需要从根本上重新思考构建这些系统的方式:从请求/响应交互转向发布/订阅数据流 更具晌应性的用户界面和更好的离线支持 读也是事件 当写入和读取都被表示为事件,并且被路由到相同的 stream operator 统一处理时,我们实际上是在查询流和数据库之间执行 stream-table join 操作。读事件需要发送到保存数据的数据库分区节点上 以日志方式记录读事件可能还可以帮助跟踪系统级别的事件因果关系和数据源: 它可以重建用户在做出某个决定之前看到的内容 多数据分区处理 对于仅涉及单个分区的查询,通过流来发送查询并收集响应事件流可能显得有些大材小用 。然而 ,这种方怯却开启了 一种分布式执行复杂查询的可能性,这需要合并来自多个分区的数据,并很好地借助底层流处理系统所提供的消息路由、分区和 join 功能。 端到端的正确性 数据库的端到端争论 仅仅因为应用程序使用了具有较强安全属性的数据系统,并不能意味着应用程序一定保证没有数据丢失或损坏,应用程序的 bug 或从数据库删除数据 Exactly-once 执行操作 使操作满足幂等性 维护额外的元数据确保节点丢失和切换中必要的 fencing 措施。 重复消除 许多模式都需要重复消除 TCP 采用序列号检测包丢失或重复,井最终确保数据包以正确的顺序接受,丢失的数据包都会被重新发送,并且在将数据交给应用程序之前,TCP 堆栈将负责删除重复的数据包。 网络不佳的情况下,客户端和服务端之间的事件可能会丢失,客户端上再次操作会导致事件重复提交 标识操作符 可以为每个事件生成一个唯一的标识符号,确保在服务端每个事件只被执行一次 端到端的争论 底层的可靠性功能本身不足以确保端到端的正确性。 在数据系统中采用端到端的思路 即使应用程序所使用的数据系统提供了比较强的安全属性,也并不意味着应用程序就一定没有数据丢失或损坏,应用程序本身也需要采取端到端的措施,例如重复消除。 事务处理的代价很高,特别是在楼及异构存储技术时 大多数的应用程序级别的容错机制无法正常工作导致了数据丢失或者损坏 探索更好的容错抽象是很有必要的 强制约束 唯一性约束需要达成共识 基于主节点做出所有的决策,就能够达成共识 无法支持异步的多主节点复制,发生写冲突,无法保证值的唯一性 单点失败,扩展性问题 基于日志的消息传递唯一性 日志机制可以确保所有消费者以相同的顺序查看消息,这种保证在形式上被称为全序关系广播,它等价于共识问题 在基于日志的消息传递的分离式数据库系统中,我们可以采用非常类似的方法来保证唯一性约束 任何可能冲突的写人都被路由到特定的分区并按顺序处理, 在每个分区内事件有唯一顺序 多分区请求处理 通过将多分区事务划分为两个不同分区的处理阶段,并使用端到端的请求 ID,实现了同样的正确性 实效性与完整性 实效性 时效性意味着确保用户观察到系统的最新状态 完整性 完整性意味着避免数据损坏,即没有数据丢失,也没有互相矛盾或错误的数据。尤其是,如果将某些派生数据集作为基础数据的视图来进行维护,派生必须做到正确, 如果完整性受到破坏, 这种不一致将是永久性的 数据流系统的正确性 可靠的流处理系统可以在不需要分布式事务和原子提交协议的情况下保持完整性 将写入操作的内容表示为单条消息,可以轻松地采用原子方式编写,这种方法非常适合事件源 使用确定性派生函数从该条消息报生所有其他状态的更新操作 通过所有这些级别的处理来传递客户端生成的请求 ID,实现端到端重复消除和容等性。 消息不可变,并支持多次重新处理派生数据,从而使错误恢复变得更容易 宽松的约束 传统的需要达成唯一性约束需要通过单节点汇聚所有分区事件实现 很多应用程序采取了弱一致性 如果万一两个人同时注册了相同的用户名或预订了同一个座位,则可以向其中一个发送道歉消息,并要求他们选择另一个,这种纠正错误的措施被称为补偿性事务 如果客户订购的商品超出当前库存,则可以追加补充库存,但需要为延误发货向客户道歉,并为他们提供折扣 很多场景中,实际上可以接受违反约束,通过后续的事务补偿来恢复最终的完整性 无需协调的数据系统 两种观察 数据流系统可以保证派生数据的完整性,无需原子提交,线性化或跨分区的同步协调。 唯一性约束要求时效性和协调性,但是只要整体上保证完整性,即使发生暂时约束破坏,可以事后进行修复,因此许多应用实际上采用宽松式的约束并没有问题。 数据流系统可以为应用提供数据管理服务而不需要协调,同时仍然提供强大的完整性保证。这种避免协调的数据系统具有很大的吸引力:与需要执行同步协调的系统相比,可以实现更好的性能和容错能力 跨数据中心多主节点异步复制系统,任何一个节点都可以独立运行,实效性弱,完整性强 同步协调只在有必要的时候使用 另一种理解协调和约束的方毡是:它们减少了由于不一致而引发的道歉数量,但是也可能降低系统的性能与可用性,并由此可能增加由于业务中断而引发的道歉数量 。你不能将道歉减少到零,但是你可以根据自己的需求找到最佳的折中方案:选择一个合适点使得既不能有太多不一致,也不能出现太多可用性问题。 信任,但要确认 总会有一些违反假设的事情发生,硬件和软件,我们不能假设依赖的基础设施不会出错 软件缺陷时的完整性 软件存在的 bug,无法通过校验和来捕获,一旦发现会造成数据的破坏,需要在设计,测试,代码检查方面经过大量的努力。 不要盲目信任承诺 硬件和软件并不能总是处于理想状态,数据损坏迟似乎只是迟早的事情而无也避免。因此,我们至少需要有办桂来查明数据是否已经损坏,以便之后修复这些数据,并试图找出错误的根掘。检查数据的完整性也被称为审计 。 成熟的系统同样会考虑不太可能的事情出错的可能性,并且主动管理这种风险。 如果想确保你的数据仍然在那,只能不断地去读取和检查。大多数情况下,情况一切正常,但万一发现异常,则越早发现问题越好。基于此,今早尝试从备份来恢复数据,否则当你丢失数据 ,你会发现连备份也已经破坏,那时将为时已晚。千万不要盲目地相信系统总是正常工作。 验证的文化 许多人认为正确性的保证是绝对的,而没有为少见但可能的数据损坏而有所准备,作者希望将来会有更多的自我验证或自我审计系统,不断的检查自身的完整性,而不是依赖盲目的信任。 可审计性的设计 基于事件的系统可以提供更好的可审计性。在事件源方法中,用户对系统中的输入都被表示为一个单一的不可变事件,并且任何结果状态的更新都是依据该事件派生而来。派生可以很确定性的执行并且是可重复的,所以通过相同版本的派生代码来处理相同的事件日志将产生相同的状态更新。 清楚地控制数据流可以使数据的来源管理更加清晰,从而使完整性检查更加可行 对于事件日志,我们可以使用哈希校验来检查存储层是否发生数据破坏 对于派生状态,我们可以重新运行对相同的事件日志执行的批处理和流处理,以检查是否得到相同的结果,甚至是并行运行一个冗余派生系统。 确定的和定义清晰数据流也有助于系统调试和跟踪系统的操作,从而确定为什么发生了某些事情。如果中间发生了意外事件,可以提供诊断能力来重现导致意外事件的相同环境,这种精准复现历史时刻的调试能力将非常有价值。 端到端论点的再讨论 检查数据系统的完整性最好以端到端的方式进行:在完整性检查中所包含的系统部件越多,则过程中某些阶段发生无告警的数据破坏的概率就越少。如果我们可以检查整个派生系统流水线是端到端正确的,那么路径中的任何磁盘、网络、服务和算法已经全部囊括在内了。 持续的端到端完整性检查可以提高你对系统正确性的信心,从而使你的发展速度更快 审计数据系统的工具 目前,将可审计性列为高优先级别关注的数据系统井不多。有些应用程序实现了内部的审计机制,例如将所有更改记录到单独的审计表中,但是保证审计日志的完整性和数据库状态仍然有些困难 做正确的事 每个系统的都有其构建目的,我们所采取的每一个行动都会产生有意或无意的后果。目的可能像赚钱一样简单,但对世界带来的影响可能远远超出我们的初衷。建立这些系统的工程师有责任仔细考虑这些后果,井有意识地决定我们想要生活在什么样的世界。 许多数据集都是关于人的: 他们的行为、他们的兴趣和他们的身份。我们必须以人性和尊重来对待这些数据。用户也是人,人的尊严是最重要的。 作者认为软件工程师如果只专注于技术而忽视其后果是不够的,道德责任也是我们要担起的责任。评判道德总是困难的,但它太重要了以至无论如何不能被忽视。 预测性分析 通过大数据算法预测一个人的犯罪倾向,自动化的系统则有可能系统地、任意地排除某个人参与社会活动,而且是在这个人没有任何犯罪证据的情况下,井且对他/她来说几乎没有上诉的机会。 偏见和歧视 算法做出的决定不一定比人类做得更好或更糟 如果在算法的输入中存在系统性偏见,那么系统很可能吸收并在最终输出中放大这种偏见 预测分析系统只是基于过去而推断,如果过去是有偏见的,它们就会把这种偏见编码下来。如果我们希望未来比过去更好,那么就需要道德想象力,而这只有人类才具备。数据和模型只应该是我们的工具,而不是我们的主人。 责任与问责 基于机器学习的评分算法通常使用更广泛的输入范围,而且更加不透明,更难理解某个特定决策是如何发生的,以及是否有人受到不公正的对待 盲目地相信数据至高无上不仅是误解的,而且是非常危险的 。随着数据驱动的决策变得越来越普遍,我们需要弄清楚如何使算陆更负责任和透明,如何避免强化现有的偏见,以及如何在错误不可避免时加以修复。 我们还需要弄清楚如何防止数据被滥用,井努力发挥数据的正面作用 反馈环路 当预测分析影响人们的生活时,特别是由于自我强化反馈环路而出现一些有害问题。由于不合适的假设,产生了这样一个隐藏在数学严谨性和数据的伪装背后的下降旋涡。 我们不是总能预测什么时候发生这样的反馈环路。然而,通过思考整个系统可以预测许多后果,这是一种被称为系统思维 的方法。我们可以尝试理解一个数据分析系统是如何响应不同的行为、结构和特征。系统是否强化和扩大了人们之间存在的差异?还是试图打击不公平性? 即使有最好的意图,我们也必须小心意外的后果 数据隐私与追踪 跟踪行为数据对于许多面向用户的在线服务变得越来越重要, 这些功能需要一定量的用户行为跟踪,井且用户也可以从中受益。但是,根据公司的商业模式,追踪往往不止于此。如果服务是通过广告获得资助的,那么广告主就是实际的客户,而用户的利益则是次要的。跟踪数据会更加详细,分析变得更加深入,数据也会被保留很长时间,以便为营销目去建立每个人的详细资 料。现在,公司和被收集数据的用户之间的关系开始变得和以往大不一样了。用户得到免费的服务,并尽可能地被引诱参与到服务中。对用户的追踪不再是服务与个人,而是服务于资助广告客户的需求。我认为这种关系可以用一个更阴暗的词来描述 : 监视。 监视 当监控被用来确定生活中重要的事情,例如保险或就业等方面的东西时,它就开始变得不那么亲切了。此外,数据分析可以揭示出令人惊讶的侵入性的事情 赞成与选择的自由 用户几乎不知道什么样的个人数据会进入到数据库,或者数据是如何保留和处理的,大多数隐私政策的条款也极尽所能地搞得含混不清。不清楚他们的数据会发生什么,用户就不能给予任何有意义的认同。通常,来自用户的数据还被用到了不是该服务的用户身上,并且该用户根本就没有同意数据收集的任何条款。 数据是通过单向过程从用户提取而来,而不是通过真正的互惠关系,也不是公平的价值交换。没有对话,用户无战选择提供多少数据以及他们会收到什么样的服务 :服务与用户之间的关系是非常不对称的 :这些条款是由服务提供商所设置,而不是由用户 由于担心服务跟踪用户而决定拒绝使用,这只对极少数拥有足够的时间和知识来充分了解隐私政策的人群可以称得上是一种选择,并且他们可以不需要担心由此可能会失去某些机会而被迫参与这些服务。然而,对于处境较差的人来说,选择自由没有意义:对他们来说,被监视变得不可避免。 数据隐私和使用 拥有隐私并不意味着一切事情都要保密:它意味着你可以自由选择向谁展示,并展示哪些东西,要公开什么,以及要保密什么。隐私权是一个决定权:每个人都能够决定在各种情况下如何在保密和透明之间取舍。这事关个人的自由和自主。 这些公司最终选择对大部分数据继续保持私密,因为泄露数据会引起可怕的后果,井且会损害它们的商业模式。关于用户的隐私信息通常是间接地被泄露,例如借助数据分析,将广告投放给特定人群 互联网服务使得在没有用户同意的情况下积累大量敏感信息更加容易,并且在用户不知情相关后果的前提下大规模地使用它。 数据作为资产和权力 数据中介公司的存在也印证了个人数据是宝贵资产的说法,这个数据中间商是一个秘密行业,从事采购、汇总、分析、推 断和兜售侵入性个人数据,主要是为了营销目的。很多初创公司主要靠它们的用户量来估价。 收集数据时,一定要综合考量 此外,审视他人但避免自我审查是最重要的权力形式之一。尽管今天的科技公司并没有公开地寻求某些权力,但是它们所积累的数据和知识给了它们很大的控制权力,而且很多是在私下进行,不在公众监督之内。 记住工业革命 数据是信息时代的关键性特征。互联网,数据存储,处理器和软件驱动的自动化正在对全球经济和人类社会产生重大影响。由此不由得联想到工业革命, 但工业革命也带来了注入环境污染,工人处境恶劣等一系列问题。 正如工业革命存在需要被管理的黑暗面一样,向信息时代的过搜也有需要面对和解决的重大问题。作者相信收集和使用数据就是其中一个 立法与自律 数据保护法可能有助于维护个人的权利 从根本上说,我认为需要对针对个人数据的技术领域有观念上转变。我们应该停止过度以用户为衡量指标,牢记用户值得尊重。我们应该主动调整数据收集和处理流程,建立和维持与那些依赖我们软件的人们之间的信任关系。我们应该主动向用户介绍他们的数据如何使用,而不是让他们蒙在鼓里全然不知 我们应该允许每个人维护自己的隐私,即控制自己的数据而不是通过监视来窃取他们的控制权 我们不应该永远保留数据,一且不再需要,就尽快清除它们 一个很有前途的方陆是通过加密协议来实施访问控制,而不仅仅是通过策略 总的来说,观念与态度的变化都是必要的。

2021 Mar 22 · 5 min