2 Java内存区域与内存溢出异常

2.2 运行时数据区域

2.2.1程序计数器

程序计数器占用空间较小,可以看作当前 线程执行字节码的行号。因此是线程独立的。

如果执行的是native方法,则该计数器为空。

2.2.2 java虚拟机栈

描述java方法的执行内存模型。每个方法执行都会创建一个栈帧(stack frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

方法调用和执行完成的过程,伴随着栈帧入栈和出栈的过程。

每个局部变量空间为32bit,其中double和long占用两个slot。

该区域有两种异常情况:栈深度超过允许深度;无法申请到足够内存

2.2.3 本地方法栈

与虚拟机栈类似,只不过用于执行native方法时使用。sun-hotspot虚拟机将两种栈合二为一。

2.2.4 java堆

最大块的内存,几乎所有对象的实例、数组都在这里分配内存

可以细分为新生代和老年代,新生代可细分为Eden,From Survivor, To Survivor空间等。

是线程共享的,但是也可以为每个线程单独划出分配缓冲区,称为TLAB空间,各个线程的对象会优先在自己的缓冲区内分配。

2.2.5 方法区

也是各个线程共享的区域,存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码数据。

相对而言,在这个区域,GC很少出现。GC主要针对该区域的常量池回收和类的卸载。

关于常量池,经常见到的考题是:

String A = "a";
String B = "a";
A==B? true;String A = "a";
String B = "" + 'a';
A==B? false;

2.2.6 运行时常量池

运行时放入常量池中的变量,例如String的intern方法。

运行时常量池是方法区的一部分

2.2.7 直接内存

主要是nio包内分配的内存,读写较为高效。直接分配本机内存,不受java堆限制,但是会受本机物理内存限制。

满时不会触发回收,只会会在fullgc时顺便回收。

2.3 HotSpot虚拟机对象

2.3.1 对象的创建

当遇到一个new指令(字节码)时,首先在常量池查找类的符号引用,检查这个类是否已经加载,如果没加载,则要触发加载流程;
采用CAS+失败重试的方式为对象分配内存(也有采用TLAB方式分配的,这样就不用CAS了)
将对象内部的内存空间全都初始化为0,保证实例字段在java代码中不赋初始值就可以使用。为什么局部变量就必须要赋值?因为局部变量可以很好地判断出有没有赋初始值,但是类的变量,实际上是共享的,无法准确的判断出代码是否已经赋初始值,而java是不允许使用未初始化的值的,因此就直接清空了。
存储对象头:元数据(用于确定是哪个类的实例),哈希码,GC分代,偏向锁等等
执行<init>方法

java堆内存的管理方式可以是:基于指针碰撞的,或者基于空闲列表的,但是分配时都需要采用CAS重试。

对象内部的数据

对象头,实例数据,对齐填充(对齐到8B)

3.GC和内存分配策略

3.2 判断对象存活与否的算法

3.2.1 引用计数法

对每个对象都添加引用计数器,当增加一个引用时,计数器+1,减少一个引用时(改为null或者该引用变量销毁),计数器-1。缺点:无法处理循环引用的问题。

3.2.2 可达性分析

通过一系列GC-ROOT的对象为起始点,从这些起始点开始向下搜索,走过的路径为引用链。当枚举完后,不在引用链上的对象即为死亡对象。

GC-ROOT包含:虚拟机栈中各个方法局部变量表内引用的对象;类静态成员、常量引用的对象;本地方法栈JNI引用的对象;所有被同步锁持有的对象;jvm内部引用对象,如基本数据类型对应的Class对象,常驻的异常对象,系统类加载器等;其它代的引用信息

jvm采用该方法进行存活判断

3.2.5 方法区回收的判断

对于废弃常量:没有任何引用该常量。

对于废弃类:该类所有的实例已经被回收、该类的ClassLoader已经被回收、Class对象没有被引用

对于大量使用反射、动态代理等动态字节码的框架,频繁装载、卸载类的场景下,回收方法区是很有意义的。

3.3 垃圾收集算法

3.3.1 分代收集理论

弱分代假说:绝大多数对象都是朝生夕灭的;
强分代假说:熬过越多次GC过程的对象就越难消亡;
基于上面两种假说,现在的垃圾回收器都是分区收集的,而且更先进的回收器都具有局部收集的特征。跨代引用假说:跨代引用相对于同代引用来说仅占极少数
这样可以记录少量的跨代引用而不用通过扫描整个老年代来获取跨代引用。

后来新的理论,则是将整个堆内存划分为小块进行回收,是对分代收集理论的拓展。

3.3.2 标记-清除

首先标记所有的死亡对象(从gc-root开始枚举),然后进行清除。

缺点:标记很慢,清除造成碎片较多。虽然清除简单,但是分配时麻烦。

3.3.3 复制算法

将内存分为大小相同的两块,当一块内存用完后,将存活的对象复制到另一块,然后交换堆指针。

优点:避免碎片,内存利用更高效

缺点:可用内存缩小为原来的一半

现代虚拟机采用该方法来回收新生代。划分为两块小survivor和一块大eden区域,每次利用一块E和一块s,当回收时,将e和s中存活的对象一次性复制到另一块s中。默认e:s=8:1.这是因为java对象大多具有短命的特点。如果一次gc中survivor不够放这么多存活的对象,则会通过分配担保等方式将对象直接放入老年代。

3.3.4 标记-整理

和标记清除类似,只不过不做清除,而是把所有的对象都向一边移动,也即压缩。虽然清除时麻烦,但是再分配内存时比较简单。

但是它是移动操作,需要更新引用,这个过程通常也得STW

分代收集

一般将java堆划分为新生代和老年代。新生代每次gc通常只有少部分存活,因此采用复制算法较好;而老年代中对象存活率较高,且没有额外的空间进行分配担保,因此采用标记-清理、标记-整理方法。

3.4垃圾收集的过程

3.4.1 枚举根节点

该过程需要保证对象的引用关系不发生变化,因此必须要进行gc停顿(但是耗时最长的查找引用链的过程可以和用户程序并行运行)。也称为StopTheWorld。

由于hotspot虚拟机进行了准确式gc,可以精准的知道内存里的数据是引用还是实际数值,所以gc-root是可以很快得知的。

3.4.2 安全点

如果记录所有的引起引用关系变化指令的地方,则会需要太多额外空间。因此采用安全点,只在安全点记录这些信息;且进行gc时,所有的线程都会跑到最近的安全点停下。

安全点主要是方法调用、循环跳转、异常跳转等可能执行较长时间的指令。

3.4.3 安全区域

安全区域指的是一段代码片段中,引用关系不会发生变化。因此在这个区域中进行GC是安全的。可以看作是安全点的拓展。

3.4.4 记忆集与卡表

记忆集和卡表都是用于记录跨区域(也可能是跨(新生/老年)代)引用的。这样通过记录当前对象被哪些外部区域引用,就可以进行分区收集,而不是扫描整个堆来获取引用关系了;

记忆精度(也就是区域的粒度,记录该区域是否被外界引用):字长精度(即32位、64位等机器字长);对象精度(记录每个obj的跨代指针,即是否被跨代引用);卡精度(可以看成是页内存的感觉,若卡段内部的obj被跨代引用,则标志位为1)

3.4.5 写屏障

记忆集可以用来缩减GC的扫描范围,但是需要维护卡表元素,则要靠写屏障。也就是在每次修改引用关系时,触发卡表的更新。

3.4.6 并发可达性分析

三色标记法:白、黑、灰。最开始所有对象都为白色,从GC-ROOT(标记为黑色)开始搜索,对于全部引用对象已经扫描过的情况,标记为黑色;对于还有引用对象没有扫描完的情况(也就是正在扫描的对象),标记为灰色。

可能存在以下两种情况:

把原本应该死亡的对象标记为存活,比如后来黑色对象又删除了一些引用;
把原本应该存活的标记成死亡,比如后来一个黑色对象引用了新的白色对象,然后这个白色对象被其它对象删去了。

前者会造成浮动垃圾,但是后者会造成严重的错误。为了避免这种错误,可以用两种方法来打破这两个条件:增量更新和原始快照。

增量更新:一旦黑色对象引用了新的白色对象,那就将它记录下来,等待引用链扫描之后再次对这些黑色节点进行扫描

原始快照:一旦灰色对象删除了白色对象的引用(不管是直接的还是简洁的),那就将这一引用链记录下来,等待扫描结束后,重新扫描这些灰色节点的引用链记录(也就是尽量保留之前的引用记录),将引用链上的标记为白色。也即一切维持扫描前的引用状态,所以称为原始快照。

3.5 经典垃圾回收器

JVM根据不同代的特点,采用不同的垃圾回收器进行垃圾回收。

Serial

用于新生代,采用复制算法,暂停所有用户线程。

单线程,但是STW时间较长,会造成很长的停顿,但是对于单核心的机器来说,这是吞吐量最高、效率最高的收集器:因为没有线程切换等额外工作,但是吞吐量高不代表用户体验好,用户可以忍受短时间的停顿,但是对于长时间的卡顿是难以接受的。

ParNew

其实就是Serial的多线程版本。但是在单核机器上效率绝不如Serial

Parallel Scavenge

以最大吞吐量为目标,适合没有太多交互,主要在后台计算的任务。

提供吞吐量和最大垃圾收集停顿时间两个参数来进行吞吐量控制。但是最大停顿时间是以减小新生代空间、牺牲吞吐量为代价换来的:1000M的垃圾,两次分别收500M和一次性收完1000M,前者的停顿时间短,但是吞吐量肯定没后面高(其实这已经有点G1的意思了)。

可选对新生代、老年代,Eden、Survivor区大小等参数进行自适应调节以满足吞吐量要求

Serial Old

是Serial的老年代版本。单线程,采用标记-整理

作为CMS出现并发GC失败时的备选预案

Parallel Old

Parallel Scavenge的老年代版本。

在吞吐量敏感的场合,可以考虑Parallel Scavenge+Parallel Old的组合

CMS收集器

Concurrent Mark Sweep是以最短回收停顿时间为目标的收集器。基于标记-清除算法实现。

分为四个步骤:初始标记,并发标记,重新标记,并发清除

初始标记:需要STW,只标记GC-ROOT能直接关联到的对象;
并发标记:与用户线程并发运行,从第一步的初始标记结果出发,找到所有的引用链;
重新标记:修正并发标记期间因用户程序继续运行产生的变动,需要STW,停顿时间会比初始标记长,但是远比并发标记短;
并发清除:清除上一步标记的对象

我的理解:

可以看到CMS将一次垃圾回收中,最费事的寻找引用链的过程,划分为了三步:- 第一步是找寻直接关联的节点- 第二步是并发对其标记- 第三步是查找修改的对象
之所以这么做,是因为发起GC时,肯定有较多对象需要回收,因此这一次查找引用链耗时很长,那就将其拆成枚举root和并发标记两个阶段,并发标记时肯定会有遗漏,此时通过第三步查漏补缺完成。查漏补缺由于和上一次查找引用链相隔时间较短,所以需要额外标记的垃圾也不多。一旦查找出所有需要清理的对象,就可以进行并发清除了——清除是可以并发的,因为没有任何对象可以引用这些对象,因此可以放心地清除掉。

缺点:cpu敏感,虽然是并发标记、并发清除,但是需要额外占用一个线程,肯定会拖慢速度;无法处理浮动垃圾,对于清除阶段出现的垃圾只能留到下一次gc再处理;由于步骤较多,因此需要预留出较多的空间以供GC过程中并发的用户程序使用,若预留的内存无法满足程序需要,则会启用备选方案Serial Old重新进行老年代的垃圾收集;基于标记-清除方法实现,因此会有较多的碎片空间,但是若开启碎片整理,又无法做到清除和用户线程并发(因为整理时需要移动内存对象,必须要STW),因此可以选择进行几次普通gc后进行一次空间压缩。

关于粗体的“步骤较多”,进一步解释一下,后续的垃圾收集器,借鉴了CMS拆分GC步骤的思想,但是在其上进一步发展,每次不回收整个堆,因此并发回收的过程也变短了,不用像CMS,由于要进行长时间的并发回收,所以预留的内存也不用这么多了。

G1收集器

Garbage First也是具有里程碑意义的垃圾收集器之一。这是因为其具有:垃圾收集器不追求一次把java堆清理干净,而是追求能够应付应用的内存分配速率。只要垃圾收集的速度能跟得上对象分配的速度,那整个系统就会良好运作。

G1相对于CMS更先进,但是消耗的内存也较多,大概要占到JAVA堆的20%(CMS只用维护新生代被老年代外部引用的记忆集即可,而G1需要维护每个region)。

其特点有:并行和并发、分代收集(可以兼顾新生和老年)、空间整合(不会有碎片问题)、可预测停顿(很靠近实时gc的特征)

内存布局采用了一个个分立大小相同的Region,新生代和老年代虽然还存在,但已不再是物理隔离的,避免java堆中全区域的垃圾收集,而是跟踪每个Region内垃圾堆回收的价值大小,优先回收价值较大的垃圾,因此可以保证G1在有限时间内获取尽可能高的收集效率。

难点:不同Region中的对象相互引用问题。在新生代、老年代分开回收时,也有该问题,老年代可能引用新生代内的的对象,反过来也可,那么单独进行新生代回收、老年代回收时就必须要考虑这个问题。

解决方法:对于每个Region(对于新生代、老年代分开的情况,则这俩分别代表俩Region)都维护一个Remembered Set,记录哪些对象被外部引用。记录的时间发生在写Ref类型指令时,虚拟机检查Ref的对象是否位于其它Region。

运作步骤:初始标记、并发标记、最终标记、筛选回收。很类似CMS。

3.6 低延迟垃圾收集器

之前的收集器,还并不算完美。标准:内存占用、吞吐量、延迟

吞吐量:相同回收时间内回收的垃圾越多越好

延迟:STW的时间长度,越来越重要。下面两款收集器都能达到10ms的停顿时间

这两款收集器的主要思路就是,将整理阶段(涉及到对象的移动,所以对应的引用地址都需要更新)的引用更新,也进行并发

3.6.1 Shenandoah收集器

可以看作是G1的进化版,分为9个阶段:初始标记、并发标记、并发清理、并发回收、初始引用更新、并发引用更新、最终引用更新、并发清理

其中稍有不同的是下面几个阶段:

并发清理1:第一个并发清理只清理整个region中没有存活对象的region并发回收:将region中存活的对象复制到其它未被使用的region中。采用读屏障和Brooks Pointers——转发指针来处理未被回收的旧对象和新对象的问题。初始引用更新:只是确保并发回收中的所有收集器线程,对象已经复制完毕并发引用更新:将引用对象的地址改为新对象最终引用更新:更新gc-root的引用,需要STW并发清理2:在这之后,所有回收集的region中都不会有存活对象了,因此将其全部回收。

转发指针:在对象头之前加上一个转发指针,如果未作移动,那就指向自己后面的对象;如果做了移动,那就指向新对象。

引入转发指针可以实现并发引用更新,这样即使采用了整理算法,在移动对象的阶段,也不用STW了。

3.6.2 收集器

和上面的s收集器都是低延迟收集器,但是采用的方法不同。

内存布局:分为小型、中型、大型。小型中型各为2MB 32MB,用于放置小、中等的对象;大型容量不固定,必须是2MB的整数倍,用于放置4MB以上的大对象。

染色指针:对于64位系统,可以管理的内存过大,因此硬件上并不使用完全的64位,操作系统层面又再次减少了地址位数,所以最终大概只支持46(x86-64 Linux)位的地址空间。但是依然很大,所以将其高4位拿出来用于存储标志信息,标志是否进入重分配集等属性。

多重映射:采用多重映射,将不同标志位的染色指针都指向同一块堆内存

工作过程:并发标记、并发预备重分配、并发重分配、并发重映射

并发标记:和G1等一样,只不过是标记在染色指针上

并发预备重分配:统计本次收集过程需要清理哪些region,将它们组成重分配集。处于重分配集中的对象将来会被复制到别的Region。

并发重分配:将重分配集中的存活对象复制到新的region上,并为重分配集中每个region维护转发表,记录旧对象到新对象的转发关系。如果用户线程访问了位于重分配集中的对象(根据染色指针的标志位即可得知),此次访问会被转发到新对象上,并且会更新引用指针的值。这称为指针的“自愈”。相比于S收集器的转发指针,只会影响第一次读写对象,后面不需要额外的指令进行转发。由于染色指针存在,因此复制完,生成转发表后,Region可以立刻释放掉。

并发重映射:修正堆中指向重分配集所有旧对象的引用,但是这一步骤并不急切,因此将其合并到下一次垃圾收集时的并发标记阶段做,可以减少一次对象的遍历。

ZGC时迄今GC的最前沿成果,停顿只与GC-root的大小相关而与堆大小无关

没有分代,完整的收集过程较长,因此会产生较多的浮动垃圾。因此在高速分配的环境中效果不太好。后续可以考虑加入分代,加速收集新生代的垃圾。

3.8 内存分配与回收策略

对象优先在Eden分配,若没有足够的空间,则发起一次Minor GC

大对象直接进入老年代:可选将较大的对象直接进入老年代分配,避免大量的内存复制(因此请勿频繁创建大对象,容易造成full gc频率太高)

长期存活的对象进入老年代:经过多次回收都进入Survivor区,则进入老年代

动态对象年龄判定:若大于等于某年龄的对象占了虚拟机一半以上的空间,则这些对象直接进入老年代

空间分配担保:进行Minor GC后,很可能Survivor区容不下所有的存活对象,此时将S无法容纳的对象直接进入老年代。如果此时老年代空间也不足,那会导致担保失败,此时要发起一次Full GC

5.项目调优实战

本章的内容虽然“没什么用”,但是通过分析过程可以更好地理解虚拟机内存管理相关的知识,达到知识的活学活用。

5.2 案例分析

5.2.1 大内存硬件上的程序部署策略

可以采用64位虚拟机或32位虚拟机集群的方式利用大内存。

前者的优势是:缓存(包括连接池等)利用率高,无需使用负载均衡器,单机部署无需考虑session同步等问题。如果采用了低延时收集器,这种方案效果也比较好。

后者的优势是:由于每个节点的堆内存比较小,所以fullgc时暂停时间短,而且不太可能出现所有的节点fullgc,保证大部分时间是可用的。尤其是在低延迟垃圾收集器还没出现的时代,这是一种更优的部署方式。

5.2.2 集群间同步导致的内存溢出

在这一案例中,导致问题的是JBossCache。它的使用场景是,集群中每个节点都会缓存一份数据,一旦更新了缓存之后,集群内会进行同步,这样导致在写入操作发生时,集群间会产生很多的写请求。

解决方法,可以采用集中式缓存,如Redis Memcached等。

5.2.3 堆外内存导致的溢出

直接内存不属于堆内存,虽然也会有垃圾回收,但是只会在老年代回收时,顺便清理一下,而不会在直接内存满时自动进行回收。因此,如果把堆内存分配得太大,剩余空间很小的话,就很容易造成直接内存溢出(即使堆内存还有很多空闲,直接内存也用不了,会直接导致溢出)

除了java堆和永久代之外,下面的区域还会占用较多的内存,虽然这些内存不受jvm堆内存限制,但是会受到操作系统单进程内存限制以及物理内存的限制:

Direct Memory 用于nio包中分配的内存
线程堆栈
socket缓冲区
jni代码
虚拟机和gc自身所需的内存

5.2.4 外部命令导致系统缓慢

尽量不要使用外部命令(如脚本,system()运行程序等)获取系统的参数,尤其是在多用户请求时采用外部命令获取。可以考虑采用缓存和语言内置的函数获取。否则,由于运行外部命令需要开新的线程,需要复制系统变量等环境,消耗较大。

5.2.5 对于两部分运行速度严重失调的情况,考虑采用消息队列来实现

5.2.6 不恰当数据结构导致内存占用过大

注意数据结构的空间效率:例如,一个HashMap<Long, Long>对象,对于每个Long对象,需要8B类型指针和8BMarkWord,也即16B的对象头。再加上自身long的数据,一共是24B的数据。两个Long包装成一个Entry,再加上16B对象头,8B的next字段,4B的hashcode,4B的内存对齐,一共是48+16+8+4+4=80B.一个Entry放于HashMap中,还需要增加引用变量8B,一共为88B,空间利用率只有18%

5.2.8 安全点导致长时间停顿

虽然一般在循环、方法调用、异常跳转等地方会设置安全点,但是hotspot对于安全点有一项优化措施,认为以int或范围更小的数字作为索引的循环,默认是不会放置安全点的。而对于long作为索引的循环则会放置安全点。
这项优化大多数时候是可行的,但是循环的时间不仅和次数有关,还和每次循环的时长有关。如果每次循环的时间消耗都很搭,那么可以将索引改为long,降低安全点的等待时间。否则,其他线程都要等这一线程跑完循环才能开始gc,就会体现得停顿时间很长

5.3 实战:Eclipse调优

升级JDK通常可以得到免费的性能提升

当永久代较小时,通常一次Full GC并不会回收多少垃圾,只是依靠永久代扩容来实现空闲空间增长,因此在内存足够的情况下,可以设置最大堆内存和最小堆内存一样大,避免前期无意义的FullGC

6.类文件结构

6.2 java平台无关性的基石

java规范分为java语言规范和java虚拟机规范。先将其它语言编译成平台无关的字节码,作为中间表示;再将字节码通过jvm翻译执行,这样就能达成一次编译,到处运行的目的了

已经有java, kotlin, grovvy, scala等语言可以运行在jvm之上。

6.3 class文件结构

主要数据:基本数据类型,u1 u2 u4 u8等无符号数;表,以无符号数和其它表复合而成

简单名称、描述符、全限定名

全限定名:org.xxx.xxx.xxx

简单名称:字段、方法的名称,例如函数名main,字段名i,j等。

描述符:用标识字符标识基本类型+void,Object,例如String类型就采用Ljava/lang/String来表示

二维数组 String [] []记录成 [[Ljava/lang/String;

整数数组 int[]记录为[I

对于方法,先参数列表(放于括号内),后返回值,例如void inc(int a, char[]b) -> (I[C])V

6.3.1 魔数(Magic Number)&版本号

魔数:0xCAFEBABE,也与其商标十分贴近。

版本号:分为主次版本号,jvm拒绝任何版本号高于自身的class文件运行。次版本号现用于表示预览版本等属性。

6.3.2 常量池

class文件的资源仓库,存储着类名、包名、描述符等。

常量池中拥有较多类型的表格,存储不同的常量。

6.3.3 访问标志

标识该类、接口的访问标志、是类还是接口等属性

6.3.4 类索引、父类索引、接口索引集合

引用常量池中的常量,用于确定:该类的全限定名,父类,接口

6.3.5 字段表集合

声明的变量的变量名、描述符、访问标志等信息

6.3.6 方法表集合

声明的方法的方法名,描述符,访问标志等信息。该表不带有真正的代码,而是指向了存储在额外属性中的Code。

有可能出现编译器自动添加的方法,如类构造器()和实例构造器()

6.3.7 属性表集合

存放额外的属性信息。

Code

存放方法体内方法编译成的字节码,参数长度,以及操作数栈最大深度,局部变量表的存储空间,异常处理表。

每个实例方法都具有默认参数“this”,用于指向当前的实例变量。所以实例方法参数表一定会有this,局部变量的第一个也存放this。

每个java方法的最大长度为65535条字节码

异常处理表:java虚拟机规范指明应通过异常表而不是跳转指令来实现异常和finally的处理机制。

每个异常表项有四个属性:from, to, handler_pc, catch_type。分别对应字节码开始、结束行数,处理程序的字节码开始行数,异常类型(指向常量池)

Exception

表明方法throws关键字后面跟着的列举的异常

LineNumberTable

用于描述源代码行号与字节码行号之间的对应关系,可用于debug、抛出异常时显示行号等

LocalVariableTable LocalVariableTypeTable

局部变量表与源码中定义的变量之间的关系,如函数参数等。

Signature

用于记录泛型类型。由于为了尽可能少地改动jvm,因此java泛型采用了伪泛型——类型擦除的方法。因此需要靠该属性来区分泛型

注解相关

6.4 字节码指令介绍

数据类型:大多数long和int都有对应的指令,但是short char byte boolean都没有, 常常转化为int来比较。因此int类型的指令是最丰富最强大的。a表示引用

加载和存储指令:将数据在局部变量表与操作数栈中来回传输(主要是load& store)

运算指令:加减乘除位运算逻辑运算

类型转换:jvm直接支持宽化范围的类型转换,而窄化则需要显式类型转换指令

对象创建与访问:创建数组、对象等

控制转移指令:如if, switch, goto等语句

方法调用和返回:

invokevirtual:调用实例方法,根据对象实际类型进行分派

invokeinterface:调用接口方法,会选出最合适的接口方法进行调用

invokespecial:调用静态指派的方法,如私有方法,父类方法、类实例初始化方法。

invokestatic:调用静态(static)方法

return:返回,具有ireturn, freturn, dreturn, areturn…

异常处理:athrow等

同步指令:也即采用了sychnorized关键字修饰的代码块,采用moniter(管程,或称为锁)实现,如monitorenter和monitorexit等。底层原理和AQS类似

7 虚拟机类加载机制

虚拟机把字节码加载到内存,校验,转换并解析、初始化,形成可以被虚拟机直接使用的java类型,整个过程被称为虚拟机的类加载机制。

7.1 类初始化的时机

1)new实例化对象,读写类的静态字段,调用类的静态方法;
2)反射调用
3)初始化子类时,先要初始化父类
4)main所在的类
5)与动态语言有关
6)与接口默认方法有关

7.3 类加载的过程

分为加载、连接、初始化、使用、卸载五个大阶段;连接可细分为验证、准备、解析三个阶段

7.3.1 加载

加载二进制流

将字节流转化为方法区运行时数据结构

内存中生成java.lang.Class对象

7.3.2 验证

验证是连接的第一个阶段。

java自身是相对安全的语言,但是生成的字节码是可以被修改而使虚拟机崩溃的。例如只读取64位数据中的32位等。

分为:

文件格式验证:主要是指.class文件中各部分是否符合class文件结构和语法
元数据验证:主要分析是否符合java语言规范,如是否有父类,是否继承了final类等
字节码验证:分析code内的字节码是否合法,如只读取64位数据中的32位等。
符号引用验证:发生在将符号引用转化为直接引用时,通常在解析阶段发生。如检查通过全限定名是否能找到对应的类、是否具有方法的访问权限等。

7.3.3 准备

为类中定义的静态变量分配内存并设置初值(基本上全是0,是系统初值,而不是程序员定义的初值);

对于static final变量,则会在此时赋值。

7.3.4 解析

将符号引用替换为直接引用时发生

7.3.5 初始化

也就是执行()的过程,在这里才会给静态变量赋程序员定义的初值

()是虚拟机自动收集类静态变量赋值和静态语句块中语句合并而成的

()是虚拟机自动加锁同步的,因此可以利用它来实现线程安全的饿汉单例模式。

7.4 类加载器

7.4.1 类与类加载器

判断两个类是否相等(包括== instanceof isAssignableFrom() equals() 这些方式来判断相等),在同一类加载器加载的前提下才有意义。否则一定是不相等。

7.4.2 双亲委派模型

如果一个类加载器收到了类加载的请求,他首先交给父类去加载这个类,如果父类无法完成加载请求,自己才尝试亲自加载。使得基础类(如Object)都是相同的类。

这里的父类,并不是通过继承实现的,而是组合关系。

启动类加载器不可被获得,获取启动类加载器,得到的一定是null。

7.4.3 破坏双亲委派模型

如何打破双亲委派模型?

7.4.1说同一类加载器加载的类才有可能相等,7.4.2又说类的加载遵循双亲委派模型,那理论自己定义的类加载器的父加载器都是应用程序类加载器,那基本上请求都委派给应用程序类加载器了,那怎么可能还有不同的问题呢?这里需要注意:双亲委派模型,是一个官方“推荐”的方式,这样基本上可以保证所有的类只要同名就相同了。但是有的时候,就希望加载的类是不相同的,咋办?比如,一个jvm里需要跑两个不同版本的java程序,那就需要使用两个类加载器来加载两套版本的类;或者后续出现的模块化,都要打破双亲委派模型才能做到。如果需要使用双亲委派模型,官方推荐重写findClass方法而不是loadClass方法。前者只是返回需要加载的类的路径,可以在双亲委派的流程中控制;而后者则是直接用当前类加载器加载,可以达到类相互隔离的目的。

7.5 java模块化系统

表明自己导出什么类,依赖什么类。不再是父子继承的层级关系,而是网状图。

简单地说一下做法,对于一个类,如果是当前模块内部的类,那就直接交给当前模块的类加载器加载;如果是系统类,交给系统类加载器加载;否则找自己import的其它模块找对应的类加载器。

8.虚拟机字节码执行引擎

通常有解释执行和编译执行两种。

《JAVA虚拟机规范》中制定了执行引擎的概念模型。也就是说,输入输出必须满足该规范,呈现一致的“外观”,但是实际执行过程可以自己实现。

8.2 栈帧结构

包含方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。

局部变量表:

以变量槽为最小单位,每个变量槽都必须要能存储int,ref等类型的数据(也就是除了double,long等类型);
(实例方法的)局部变量表中,第0个变量默认是实例对象的引用,可以采用this.来访问这个隐含参数;
变量槽是可以被复用的,因此局部变量表的大小不一定等同于申请的局部变量的大小。

方法返回地址:有两种方法退出方法,一种称为正常调用完成,遇到方法内的返回指令时将返回值(也可能没有)传递给上层的方法调用者。第二种被称为异常调用完成,是遇到了异常,且该异常并没有被妥善处理(没有在异常表中搜索到匹配的异常处理器),该情况是不会给上层调用者提供任何返回值的。因此throw也算是return的一种~

8.3方法调用

还未涉及方法中的代码执行,只是确定被调用方法的版本,也就是把符号引用转化为直接引用。

解析阶段:在类加载阶段会将一部分符号转化为直接引用。这部分符号必须是可以在程序运行前就确定调用版本,该版本在运行期不可改变。例如:invokestatic方法(静态方法,和类高度绑定),invokespecial方法如方法(实例构造器)、私有方法、父类中的方法,再加上final方法(表示不可被继承)。

8.3.2分派

面向对象的三个特征:继承、封装和多态。

静态类型:如函数参数中Type xxx,声明变量时的Type xxx中的参数类型Type

实际类型:参数、变量的实际类型。例如Queue xxx = new LinkedList<>(),实际类型是LinkedList<>;

静态分派:根据参数静态类型确定重载的函数,静态分派发生在编译阶段,即形成invokexxx字节码时,由编译器而不是虚拟机确定重载的函数。

动态分派:运行时根据参数实际类型确定重载的函数。例如静态类型为Queue的xxx可能调用了子类的被重写的函数。做法是:invokevirtual会先确定对象的实际类型,然后查找子类的该方法并校验访问权限。然后再查找父类。如果最终没有找到合适的方法,抛出异常。字段(即类实例的变量)不是虚的,访问到的就是静态类型所能看到的那个变量。

单分派与多分派:方法的接收者和方法的参数称为方法的宗量。单分派指指根据一个宗量对目标方法进行选择。静态分配为多宗量(形成invoke指令的时候既和静态方法的接收者有关,也和方法参数有关),动态分派为单宗量,即方法的接收者的实际类型。

8.4动态类型语言支持

动态语言和静态语言:动态语言的关键特征为类型检查的主体过程是在运行期而不是编译期进行的;而静态语言则是在编译期进行的。例如cpp,java就为静态语言,而python为动态语言。

JDK7中引入了Invokedynamic指令实现对动态语言的支持。之前的static,special,virtual都在编译期就把方法的符号引用写入了字节码中,但是动态语言的类型在运行期才能确定,所以之前只能采用占位符、运行时生成动态字节码的方式来实现动态调用,增加了很多的内存和性能开销。

现在有了该指令后,java还支持了lambda表达式,形式上很像动态语言。

8.5 基于栈的字节码解释引擎

基于寄存器:快速,但是各个硬件的寄存器差异很大,所以不便移植

基于栈:代码更紧凑(不用带着操作数),易于移植,执行速度较慢(入栈出栈指令多)

虚拟机内部可能进行了较多优化,如hotspot就有一些非标准的fast_指令集

9.类加载及执行子系统的案例与实战

本章主要为实例分析。

9.1 类加载器的实例:Tomcat

Tomcat是主流Java Web服务器之一。Web服务器得类加载器需要满足如下条件:

1.部署在同一服务器上的两个Web程序类库相互隔离,也就是不同的程序可以使用不同(自己指定)版本的类库

2.部署在同一服务器上的两个Web程序类库也可以相互共享。节约磁盘空间

3.服务器尽可能保证自身安全不受部署的应用程序影响。也就是服务器与应用程序类库隔离

4.支持代码热替换,避免升级程序时整个服务器都要停止

Tomcat通常有四大类库,common,server,shared,WEB-INF,分别对应公用库,服务器专用库,应用程序共享库,各个应用自身专用库。

因此定义了多个类加载器:server类加载器、shared类加载器都继承于common类加载器;应用程序加载器继承于shared类,jsp继承于webapp类,如下图所示。其中,catalina类加载器即为server类加载器。这一结构就完全满足了上面的要求。

9.2 字节码生成技术和动态代理的实现

动态代理即为相对于静态代理来说,可以在原始类和接口都未知的情况下,就确定代理行为,使得代理类可以灵活适用于各种不同应用场景中。

10.前端编译与优化

10.1 概述

java语言的编译分为将java代码转换为字节码和字节码转化为本地机器码的过程。其中,有多种编译器参与,如javac做的是代码转化为字节码,JIT是在运行期将字节码转变为机器码,AOT是直接将代码转化为二进制码。

前端编译器:JAVAC,ECJ等;

JIT:C1,C2,Graal等;

AOT:jaotc,GCJ等。

本章节讨论的为前端编译器。前端编译器基本不采用任何的优化,主要的性能优化都集中在运行期的JIT中,这样可以使其他语言产生的class文件也能享受jvm的优化。但是javac大大降低了程序员编程的复杂度,提升了编码效率(如语法糖)

10.2 javac编译器

主要分为三个步骤:

1.解析与填充符号表:词法语法分析,构造AST;填充符号表,产生符号地址与符号信息

2.插入式注解处理器

3.分析与字节码生成:标注检查(变量是否声明?赋值的类型匹配吗?进行常量折叠优化),数据流控制流分析(代码不可达?修改了final变量?),解语法糖(如泛型、自动拆装箱等);字节码生成等。

10.3 java的语法糖

泛型:

类型擦除,虚拟机为了保持向前兼容并不原生支持泛型,因此属于语法糖。

不能创建泛型的数组,不能创建泛型对象,不能使用isInstance判断泛型实例。

不可使用基本类型作为泛型参数,自动装箱拆箱性能损失很大。

不需要改动虚拟机,保持了向前兼容的承诺。

前端编译器自动插入强制类型转换代码,就像泛型没有出现之前,程序员使用Object类实现泛型一样。由于这个原因,也不支持原始类型(如int,long等)的泛型,所以原始类的泛型会自动插入自动拆装箱代码。

运行期也无法获取泛型类型信息。

泛型方法重载也不行。如参数List,List就会认为是一样的, 无法重载。

自动装拆箱、变长参数与遍历循环:

自动拆装箱:编译器插入.valueOf()和.intValue()等函数,完成自动拆装箱,即基本类型向装箱类型转换。

变长参数即参数含有一个Object[]数组。

遍历循环for(type a: arr)插入了迭代器。

装箱类的坑:Integer的-128~127是自动缓存的,可以使用==判断正误,但是超出该范围的Integer必须使用.equals()判断,否则会出现令人难懂的错误。

条件编译

if(const){}else{}即可达成C CPP中的条件编译。const必须为boolean类型常量。

11.后端编译和优化

本章标题从第二版的“运行期优化”变成了“后端优化”,这是因为在当时,JIT是主流的编译形式,AOT虽然有,但是很少被应用。

jvm规范并未规定jvm必须包含jit或者aot,但是java编译性能的好坏和代码优化质量是衡量商用jvm优秀与否的关键指标之一,核心,且是最体现技术水平和价值的功能。

11.2 JIT

11.2.1 解释器与编译器

解释器可以加快程序启动速度,jit可以降低解释的中间消耗,获得更高的执行效率。此外,解释器可以充当编译器激进优化(也即大部分时候能成功的优化,可能在小部分时候失败)的后备逃生门。

内置了c1 c2 graal三种jit,最后一种还处于试验阶段。c1 c2会根据jvm运行于客户机还是服务机模式选择。

运行期的编译可以通过监测性能数据来进行激进的优化。

11.2.2触发编译的条件

主要是热点代码,如多次调用的方法和多次执行的循环体。通过计数(或采样)方式,如指定时间内的调用次数等。后者采用灰边计数。

一般为异步编译,jvm发出编译请求后,继续执行,等完全编译完成后,再调用编译后的版本。

11.3 AOT

AOT实际上违背了JAVA的“一次编译,到处运行”的特性,因此在很长一段时间内都没有被重视。但是2013年,安卓问世后,使用AOT的安卓ART虚拟机直接打败了使用JIT的Dalvik虚拟机,因此对java世界也产生了影响。

11.3.1 AOT的优劣得失

JIT消耗运行时时间进行编译,即使效率再高,也是占用了运行时时间。而且程序的高度优化很占用时间和资源。

AOT副作用:在安卓5和6版本,安装大程序需要几分钟时间,因此安卓7重新启用了解释执行和JIT,在系统空闲时自动进行AOT。同时,消除了平台无关性,还有字节膨胀、难以动态扩展等问题。

麻烦之处:提前编译结果不仅于机器高度相关,还于jvm运行时参数高度相关,例如采用的gc是哪种,内存屏障等指令插入的地方就不一样。

JIT相对于AOT的优势:进行性能分析制导优化,将缓存、分支预测等资源分配给概率更高的热点代码;激进预测优化,按照高概率行为进行优化;链接时优化,可以进行全局优化,而cpp等是不可能对外部加载的如dll内部的代码进行优化的。

10.4编译器优化技术

10.4.2 方法内联

即将方法调用开销消除,变为更直接的代码。还可以为后续的大范围优化创造更好的条件(更有全局视野)

难点:虚方法内联?

10.4.3 逃逸分析

所有java对象都在堆内分配,但是如果可以证明一个对象不会逃逸到方法或者线程之外,就可以进行更好的优化。这是优化的前沿技术。

栈上分配:随方法共生共灭,减轻gc压力,支持方法逃逸不支持线程逃逸

标量替换:将类拆分为几个原始数据类型,直接对这些原始类型进行访问,避免了类创建、寻址等的开销。可视为栈上分配的特例。不支持方法和线程逃逸。

同步消除:如果没有线程逃逸,就不需要进行线程同步。

10.4.4公因子表达式消除

替换计算的中间值,避免重复计算(例如秦九韶算法,消除了很多乘法)

10.4.5数组边界检查消除

对于确定的不会越界的数组访问(例如使用循环访问数组),取消数组边界检查。

12.Java内存模型与线程

12.1 概述

多线程的原因:摩尔定律的失效;运算速度远远大于存储和通信的速度

12.2 硬件的效率与一致性

为了解决存储和运算之间几个数量级的速度差异,引入一层或多层缓存来作为处理器与内存之间的缓冲。

缓存的核心问题:缓存一致性。多处理器、多核系统,多个处理器都有自己的高速缓存,但又共享同一主存。当缓存数据同步回内存时,会出现问题。

为了充分利用处理器内部的运算单元,引入了乱序执行,可以保证执行结果与顺序执行结果一致。

乱序执行核心问题:在线程内部看,结果是一致的,也是有序的;在线程外部看,则可能是乱序的,并且使结果出错。

12.3 JAVA内存模型

12.3.1 主内存与工作内存

所有的变量都存储在主内存(类似主存,但是这里只是虚拟机的内存)中,每条线程都有自己的工作内存(类似缓存)。线程的工作内存中保存了被该线程使用的主内存副本,线程对变量的操作都在工作内存中进行,不能直接读写主存;不同线程也不能访问对方的工作内存

12.3.2 内存间交互操作

lock/unlock:作用于主内存变量,标识为线程独占

read/write:将主存变量读取到工作区,以便随后的load使用(read)

load/store:将read来的变量存储到工作区的变量副本中

use/assign:将工作内存中的变量传递给执行引擎,也即遇到一句读取变量值的字节码时就会执行这个操作

规则:read后必须load,不一定要紧挨着,但是要保证从主存中读取的变量工作内存一定要接受,write同理;assign后必须要同步回主存;没发生assign不允许同步回主存;lock是可计数的;unlock后必须同步回主存……

12.3.3 volatile关键字

保证了变量的可见性。也就是当该变量发生改变时,其它线程立马就能知道。但是并非线程安全,尤其是在需要修改该变量时。

禁止指令重排序。例如init类时,可能由于重排序,先将标志变量赋值,再初始化,就会造成错误。

12.3.5 原子性、可见性和有序性

原子性:保证操作中间不会被打断

可见性:一个线程修改了共享变量的值时,其它线程能立即得知。final字段也可以实现可见性

有序性:主要是指某些指令必须在前面的指令执行完成后才能执行。

12.3.6 先行发生原则

表明线程A做出的改变能被线程B观测到,那么A先于B发生。即使A在时间上先发生,但是B无法观测到,那也不能说A先于B发生。

12.4 JAVA与线程

12.4.1 线程的实现

内核线程:需要占用内核空间,因此内核线程是有限的

用户线程:只在用户态,多个用户线程可以只对应一个内核线程。但是需要处理线程的创建销毁切换和调度问题,例如,如果挂起用户线程中的一个线程(如进行系统调用),是否需要挂起整个内核线程?如果挂起整个内核线程,那么同一内核线程上的所有用户线程都会被挂起。

混合实现:上面两种的混合实现。

Java的实现:基本上是内核线程

12.4.2 JAVA线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要分为协同式和抢占式调度。

协同式:指的是线程执行时间由线程自身控制,当它的工作执行完成后,通知系统切换到另一线程上。实现简单,几乎没有同步问题,但是不够稳定,容易造成系统崩溃。

抢占式:每个线程由系统来分配执行时间,线程可以主动让出时间(如Thread.yield()),但是最长时间受系统控制。

12.4.3 状态转换

新建
运行
无限期等待:需要等待其它线程显式唤醒,如实现Lock接口的那些锁的阻塞
限期等待:等待一段时长后被唤醒
阻塞:等待某个事件发生,如IO、系统调用、synchronized等
结束

12.5 JAVA与协程

12.5.1 内核线程局限

用户态和内核态切换的消耗很大,有时候甚至大于执行的代码本身

12.5.2 协程的复苏

为何内核线程调度切换成本高:响应中断、保护和恢复执行现场的成本

协程的主要优势是轻量

13.线程安全与锁优化

13.2 线程安全

当多个线程同时访问、修改一个对象时,如果不用考虑这些线程的操作运行时的调度和交替执行,无需额外同步,或者在调用方进行任何其它的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

13.2.1 JAVA语言中的线程安全

1.不可变:如final

2.绝对线程安全:不论运行时环境如何,调用者都无需额外的同步措施

3.相对线程安全:对象的反复进行某个操作时,是安全的。类似于方法是经过同步的。

4.线程兼容:虽然不是线程安全的,但是可以在调用端使用同步手段保证线程安全。

5.线程对立:无论是否采用了同步,都无法在多线程环境中并发使用。如suspend()和resume()方法等。

13.2.2 线程安全的实现方法

互斥同步:如上锁,synchronized关键字等

非阻塞同步:采用CAS等方式进行同步,但是无法涵盖互斥同步的所有使用场景。

无同步方案:可重入代码,对于相同的输入,一定会有相同的输出;线程本地存储,使用共享数据的代码都在同一线程内运行

13.3 锁优化

自旋锁:空转等待锁释放,避免陷入内核态

锁消除:对于没有逃逸出线程的数据,可以不采用同步措施

锁粗化:对于需要频繁对同一对象上锁的代码(如循环内上锁),将同步的范围扩大,避免频繁上锁解锁

轻量级锁:采用CAS设置对象头,表示该对象已被上锁;如果出现两个对象竞争同一个锁,那就要膨胀为重量级锁。原理是,一个对象的整个同步过程内,大部分时候都不会出现竞争。

偏向锁:偏向于第一个获取该锁的对象。如果另一个线程尝试获取该锁,偏向模式结束,后续都按照轻量级锁的形式执行。用于提高使用了同步但是无竞争的程序的性能。如果该锁总被多个不同线程访问,那偏向模式就是多余的。

《深入理解JVM 第三版》 读书笔记相关推荐

  1. 读书笔记 | 墨菲定律

    1. 有些事,你现在不做,永远也不会去做. 2. 能轻易实现的梦想都不叫梦想. 3.所有的事都会比你预计的时间长.(做事要有耐心,要经得起前期的枯燥.) 4. 当我们的才华还撑不起梦想时,更要耐下心来 ...

  2. 读书笔记 | 墨菲定律(一)

    1. 有些事,你现在不做,永远也不会去做. 2. 能轻易实现的梦想都不叫梦想. 3.所有的事都会比你预计的时间长.(做事要有耐心,要经得起前期的枯燥.) 4. 当我们的才华还撑不起梦想时,更要耐下心来 ...

  3. 洛克菲勒的38封信pdf下载_《洛克菲勒写给孩子的38封信》读书笔记

    <洛克菲勒写给孩子的38封信>读书笔记 洛克菲勒写给孩子的38封信 第1封信:起点不决定终点 人人生而平等,但这种平等是权利与法律意义上的平等,与经济和文化优势无关 第2封信:运气靠策划 ...

  4. 股神大家了解多少?深度剖析股神巴菲特

    股神巴菲特是金融界里的传奇,大家是否都对股神巴菲特感兴趣呢?大家对股神了解多少?小编最近在QR社区发现了<阿尔法狗与巴菲特>,里面记载了许多股神巴菲特的人生经历,今天小编简单说一说关于股神 ...

  5. 2014巴菲特股东大会及巴菲特创业分享

     沃伦·巴菲特,这位传奇人物.在美国,巴菲特被称为"先知".在中国,他更多的被喻为"股神",巴菲特在11岁时第一次购买股票以来,白手起家缔造了一个千亿规模的 ...

  6. 《成为沃伦·巴菲特》笔记与感想

    本文首发于微信公众帐号: 一界码农(The_hard_the_luckier) 无需授权即可转载: 甚至无需保留以上版权声明-- 沃伦·巴菲特传记的纪录片 http://www.bilibili.co ...

  7. 读书笔记002:托尼.巴赞之快速阅读

    读书笔记002:托尼.巴赞之快速阅读 托尼.巴赞是放射性思维与思维导图的提倡者.读完他的<快速阅读>之后,我们就可以可以快速提高阅读速度,保持并改善理解嗯嗯管理,通过增进了解眼睛和大脑功能 ...

  8. 读书笔记001:托尼.巴赞之开动大脑

    读书笔记001:托尼.巴赞之开动大脑 托尼.巴赞是放射性思维与思维导图的提倡者.读完他的<开动大脑>之后,我们就可以对我们的大脑有更多的了解:大脑可以进行比我们预期多得多的工作:我们可以最 ...

  9. 读书笔记003:托尼.巴赞之思维导图

    读书笔记003:托尼.巴赞之思维导图 托尼.巴赞的<思维导图>一书,详细的介绍了思维发展的新概念--放射性思维:如何利用思维导图实施你的放射性思维,实现你的创造性思维,从而给出一种深刻的智 ...

  10. 产品读书《滚雪球:巴菲特和他的财富人生》

    作者简介 艾丽斯.施罗德,曾经担任世界知名投行摩根士丹利的董事总经理,因为撰写研究报告与巴菲特相识.业务上的往来使得施罗德有更多的机会与巴菲特亲密接触,她不仅是巴菲特别的忘年交,她也是第一个向巴菲特建 ...

最新文章

  1. Maya角色面部表情动画制作视频教程 Maya: Facial Rigging
  2. [处理器、单片机]ARM
  3. python 查看数据结构类型_python标准数据结构类型
  4. 神策数据薛创宇:数据分析与场景实践之“坑位运营”
  5. C语言学习之编写一个C程序,运行时输人abc三个值,输出其中值最大者。
  6. JavaScript--DOM操作表格及样式(21)
  7. python字符串截取方法_如何使用python语言中的字符串方法截取字符串
  8. python3 抽象基类 abc.abstractmethod
  9. ERROR: Failed to Setup IP tables: Unable to enable SKIP DNAT rule
  10. 移动端自适应方案(转载)
  11. 九阴真经 服务器列表文件,九阴真经合服_九阴真经数据互通_九阴真经公告_快吧游戏...
  12. webfreer去广告
  13. (转)(异常分析) org.hibernate.MappingException: entity class not found
  14. Rabbitmq交换机详解
  15. jq 移动端网页分享功能_js实现QQ、微信、新浪微博分享功能
  16. 阿里云ECS服务器安装宝塔BT面板图文教程
  17. 猜数字小游戏(网页版)
  18. poi导出excel不可读
  19. 基于分布式微服务的SAAS统一认证平台
  20. error: expected declaration or statement at end of input 解决方法

热门文章

  1. 2021-2027全球与中国BIM对象软件市场现状及未来发展趋势
  2. HTTP协议报文解析
  3. 速营社:谈谈我们需要怎样的媒体人
  4. C# 对数字取整和求余
  5. web——CSS精灵图(背景图、定位背景图片background-position属性)
  6. 使用华为云CSE开发微服务应用
  7. 中移物联技术总监肖青:中移物联网eSIM相关进展介绍
  8. 使用栈实现计算器java(括号、加减、乘除)v2.0
  9. Android中谷歌翻译接口使用(使用谷歌翻译接口,App做文本翻译)
  10. 无法读取服务器php文件mime类型,php 常见文件MIME类型