微信公众号:内核小王子 关注可了解更多关于数据库,JVM内核相关的知识; 如果你有任何疑问也可以加我pigpdong[^1]
jvm 一行代码是怎么运行的
首先,java代码会被编译成字节码,字节码就是java虚拟机定义的一种编码格式,需要java虚拟机才能够解析,java虚拟机需要将字节码转换成机器码才能在cpu上执行。 我们可以用硬件实现虚拟机,这样虽然可以提高效率但是就没有了一次编译到处运行的特性了,所以一般在各个平台上用软件来实现,目前的虚拟机还提供了一套运行环境来进行垃圾回收,数组越界检查,权限校验等。虚拟机一般将一行字节码解释成机器码然后执行,称为解释执行,也可以将一个方法内的所有字节码解释成机器码之后在执行,前者执行效率低,后者会导致启动时间慢,一般根据二八法则,将百分之20的热点代码进行即时编译。JIT编译的机器码存放在一个叫codecache的地方,这块内存属于堆外内存,如果这块内存不够了,那么JIT编译器将不再进行即时编译,可能导致程序运行变慢。
jvm如何加载一个类
第一步:加载,双亲委派:启动类加载器(jre/lib),系统扩展类加载器(ext/lib),应用类加载器(classpath),前者为c++编写,所以系统加载器的parent为空,后面两个类加载器都是通过启动类加载器加载完成后才能使用。加载的过程就是查找字节流,可以通过网络,也可以自己在代码生成,也可以来源一个jar包。另外,同一个类,被不同的类加载器加载,那么他们将不是同一个类,java中通过类加载器和类的名称来界定唯一,所以我们可以在一个应用成存在多个同名的类的不同实现。
第二步:链接:(验证,准备,解析) 验证主要是校验字节码是否符合约束条件,一般在字节码注入的时候关注的比较多。准备:给静态字段分配内存,但是不会初始化,解析主要是为了将符号引用转换为实际引用,可能会触发方法中引用的类的加载。
第三步:初始化,如果赋值的静态变量是基础类型或者字符串并且是final的话,该字段将被标记为常量池字段,另外静态变量的赋值和静态代码块,将被放在一个叫cinit的方法内被执行,为了保证cinit方法只会被执行一次,这个方法会加锁,我们一般实现单例模式的时候为保证线程安全,会利用类的初始化上的锁。 初始化只有在特定条件下才会被触发,例如new 一个对象,反射被调用,静态方法被调用等。
java对象的内存布局
java中每一个非基本类型的对象,都会有一个对象头,对象头中有64位作为标记字段,存储对象的哈希码,gc信息,锁信息,另外64位存储class对象的引用指针,如果开启指针压缩的话,该指针只需要占用32位字节。
Java对象中的字段,会进行重排序,主要为了保证内存对齐,使其占用的空间正好是8的倍数,不足8的倍数会进行填充,所以想知道一个属性相对对象其始地址的偏移量需要通过unsafe里的fieldOffset方法,内存对齐也为了避免让一个属性存放在两个缓存行中,disruptor中为了保证一个缓存行只能被一个属性占用,也会用空对象进行填充,因为如果和其他对象公用一个缓存行,其他对象的失效会将整个缓存行失效,影响性能开销,jdk8中引入了contended注解来让一个属性独占一个缓存行,内部也是进行填充,用空间换取时间,如何计算一个对象占用多少内存,如果不精确的话就进行遍历然后加上对象头,这种情况没办法考虑重排序和填充,如果精确的话只能通过javaagent的instrument工具。
反射的原理
反射真的慢么?
首先class.forname和class.getmethod 第一个是一个native方法,第二个会遍历自己和父类中的方法,并返回方法的一个拷贝,所以这两个方法性能都不好,建议在应用层进行缓存。 而反射的具体调用有两种方式,一种是调用本地native方法,一种是通过动态字节码生成一个类来调用,默认采用第一种,当被调用15次之后,采用第二种动态字节码方式,因为生成字节码也耗时,如果只调用几次没必要,而第一种方式由于需要在java和c++之间切换,native 方法本身性能消耗严重,所以对于热点代码频繁调用反射的话,性能并不会很差。
属性的反射,采用unsafe类中setvalue来实现,需要传入该属性相对于对象其始地址的偏移量,也就是直接操作内存。其实就是根据这个属性在内存中的起始地址和类型来读取一个字段的值,在LockSupport类中,park和unpark方法,设置谁将线程挂起的时候也有用到这种方式。
动态代理
java本身的动态代理也是通过字节码实现的
Proxy.newProxyInstance(ClassLoader loader,Class>[] interfaces,InvocationHandler h)
工具类中需要提供 类加载器,需要实现的接口,拦截器的实现,也就是需要在InvocationHandler中调用原方法并做增强处理。并且这个实现,一定会被放到新生成的动态代理类里。
生成动态代理类的步骤:先通过声明的接口生成一个byte数组,这个数组就是字节流,通过传入的类加载进行加载生成一个class对象,这个class 里面有个构造方法接收一个参数,这个参数就是InvocationHandler,通过这个构造方法的反射获取一个实例类,在这个class里面,接口的实现中会调用InvocationHandler,而这个class对象为了防止生成太多又没有被回收,所以是一个弱引用对象。
jvm的内存模型
并发问题的根源:可见性,原子性,乱序执行
java内存模型定义了一些规则来禁止cpu缓存和编译器优化,happen-before用来描述两个操作的内存的可见性,有以下6条
- 1.程序的顺序执行,前一个语句对后一个语句可见 (当两个语句没有依赖的情况下还是可以乱序执行)
- 2.volatile变量的写对另一个线程的读可见
- 3.happen-before 具有传递性
- 4.一个线程对锁的释放对另外一个线程的获取锁可见 (也就是一个线程在释放锁之前对共享变量的操作,另外一个线程获取锁后会看的到)
- 5.线程a调用了线程b的start()方法,那么线程a在调用start方法之前的操作,对线程b内的run()方法可见
- 6.线程a调用了线程b的join方法,那么线程b里的所有操作,将对线程a调用join之后的操作可见。
jvm的垃圾回收
两种实现:引用计数和可达性分析,引用计数会出现循环引用的问题,目前一般采用可达性分析。
为了保证程序运行线程和垃圾回收线程不会发生并发影响,jvm采用安全点机制来实现stop the world,也就是当垃圾收集线程发起stop the world请求后,工作线程开始进行安全点检测,只有当所有线程都进入安全点之后,垃圾收集线程才开始工作,在垃圾收集线程工作过程中,工作线程每执行一行代码都会进行安全点检测,如果这行代码安全就继续执行,如果这行代码不安全就将该线程挂起,这样可以保证垃圾收集线程运行过程中,工作线程也可以继续执行。
安全点:例如阻塞线程肯定是安全点,运行的jni线程如果不访问java对象也是安全的,如果线程正在编译生成机器码那他也是安全的,Java虚拟机在有垃圾回收线程执行期间,每执行一个字节码都会进行安全检测。
基础垃圾收集算法:清除算法会造成垃圾碎片,清除后整理压缩浪费cpu耗时,复制算法浪费内存。
基础假设:大部分的java对象只存活了一小段时间,只有少部分java对象存活很久。新建的对象放到新生代,当经过多次垃圾回收还存在的,就把它移动到老年代。针对不同的区域采用不同的算法。因为新生代的对象存活周期很短,经常需要垃圾回收,所以需要采用速度最快的算法,也就是复制,所以新生代会分成两块。一块eden区,两块大小相同的survivor区。
新的对象默认在eden区进行分配,由于堆空间是共享的,所以分配内存需要加锁同步,不然会出现两个对象指向同一块内存,为了避免频繁的加锁,一个线程可以申请一块连续内存,后续内存的分配就在这里进行,这个方案称为tlab。tlab里面维护两个指针,一个是当前空余内存起始位置,另外一个tail指向尾巴申请的内存结束位置,分配内存的时候只需要进行指针加法并判断是否大于tail,如果超过则需要重新申请tlab。
如果eden区满了则会进行一次minorGc ,将eden区的存活对象和from区的对象移动到to区,然后交换from和to的指针。
垃圾收集器的分类:针对的区域,老年代还是新生代,串行还是并行,采用的算法分类复制还是标记整理
g1 基于可控的停顿时间,增加吞吐量,取代cms g1将内存分为多个块,每个块都可能是 eden survivor old 三种之一 首先清除全是垃圾的快 这样可以快速释放内存。
如果发现JVM经常进行full gc 怎么排查?
不停的进行full gc表示可能老年代对象占有大小超过阈值,并且经过多次full gc还是没有降到阈值以下,所以猜测可能老年代里有大量的数据存活了很久,可能是出现了内存泄露,也可能是缓存了大量的数据一直没有释放,我们可以用jmap将gc日志dump下来,分析下哪些对象的实例个数很多,以及哪些对象占用空间最多,然后结合代码进行分析。
并发和锁
线程的状态机
线程池参数:核心线程数,最大线程数,线程工厂,线程空闲时间,任务队列,拒绝策略 先创建核心线程,之后放入任务队列,任务队列满了创建线程直到最大线程数,在超过最大线程数就会拒绝,线程空闲后超过核心线程数的会释放,核心线程也可以通过配置来释放,针对那些一天只跑一个任务的情况。newCachedThreadPool线程池会导致创建大量的线程,因为用了同步队列。
synchronized
同步块会有一个monitorenter和多个monitorexist ,重量级锁是通过linux内核pthread里的互斥锁实现的,包含一个waitset和一个阻塞队列。 自旋锁,会不停尝试获取锁,他会导致其他阻塞的线程没办法获取到锁,所以他是不公平锁,而轻量级锁和偏向锁,均是在当前对象的对象头里做标记,用cas方法设置该标记,主要用于多线程在不同时间点获取锁,以及单线程获取锁的情况,从而避免重量级锁的开销,锁的升级和降级也需要在安全点进行。
- reentrantlock相对synchronized的优势:可以控制公平还是非公平,带超时,响应中断。
- CyclicBarrier 多个线程相互等待,只有所有线程全部完成后才通知一起继续 (调用await 直到所有线程都调用await才一起恢复继续执行)
- countdownlatch 一个线程等待,其他线程执行完后它才能继续。(调用await后被阻塞,直到其他地方调用countdown()将state减到1 这个地方的其他可以是其他多个线程也可以其他单个任务)
- semaphore 同一个时刻只运行n个线程,限制同时工作的线程数目。
- 阻塞队列一般用两个锁,以及对应的条件锁来实现,默认为INTEGER.MAX为容量,而同步队列没有容量,优先级队列内部用红黑树来实现。
如果要频繁读取和插入建议用concurrenthashmap 如果频繁修改建议用 concurrentskiplistmap,copyonwrite适合读多写少,写的时候进行拷贝,并加锁。读不加锁,可能读取到正在修改的旧值。concurrent系列实际上都是弱一致性,而其他的都是fail-fast,抛出ConcurrentModificationException,而弱一致性允许修改的时候还可以遍历。例如concurrent类的size方法可能不是百分百准确。
AQS 的设计,用一个state来表示状态,一个先进先出的队列,来维护正在等待的线程,提供了acquire和release来获取和释放锁,锁,条件,信号量,其他并发工具都是基于aqs实现。
字符串
字符串可以通过intern()方法缓存起来,放到永久代,一般一个字符串申明的时候会检查常量区是否存在,如果存在直接返回其地址,字符串是final的,他的hashcode算法采用31进制相加,字符串的拼接需要创建一个新的字符串,一般使用stringbuilder。String s1 = "abc"; String s2 = "abc"; String s1 = new String("abc"); s1和s2可能是相等的,因为都指向常量池。
集合
- vector 线程安全,arraylist 实现 randomaccess 通过数组实现支持随机访问,linkedlist 双向链表可以支持快速的插入和删除。
- treeset 依赖于 treemap 采用红黑树实现,可以支持顺序访问,但是插入和删除复杂度为 log(n)
- hashset 依赖于 hashmap 采用哈希算法实现,可以支持常数级别的访问,但是不能保证有序
- linkedhashset 在hashset的节点上加了一个双向链表,支持按照访问和插入顺序进行访问
- hashtable早版本实现,线程安全 不支持空键。
- hashmap:根据key的hashcode的低位进行位运算,因为高位冲突概率较高,根据数组长度计算某个key对应数组位置,类似求余算法,在put的时候会进行初始化或者扩容,当元素个数超过 数组的长度乘以负载因子的时候进行扩容,当链表长度超过8会进行树化,数组的长度是2的多少次方,主要方便位运算,另一个好处是扩容的时候迁移数据只需要迁移一半。当要放 15个元素的时候,一般数组初始化的长度为 15/0.75= 20 然后对应的2的多少次方,那么数组初始化长度为 32.
- ConcurrentHashMap 内部维护了一个segment数组,这个segment继承自reentrantlock,他本身是一个hashmap,segment数组的长度也就是并发度,一般为16. hashentry内部的value字段为volatile来保证可见性.size()方法需要获取所有的segment的锁,而jdk8的size()方法用一个数组存储每个segment对应的长度。
io
输入输出流的数据源有 文件流,字节数组流,对象流 ,管道。带缓存的输入流,需要执行flush,reader和writer是字符流,需要根据字节流封装。
bytebuffer里面有position,capcity,limit 可以通过flip重置换,一般先写入之后flip后在从头开始读。
文件拷贝 如果用一个输入流和一个输出流效率太低,可以用transfer方法,这种模式不用到用户空间,直接在内核进行拷贝。
一个线程一个连接针对阻塞模式来说效率很高,但是吞吐量起不来,因为没办法开那么多线程,而且线程切换也有开销,一般用多路复用,基于事件驱动,一个线程去扫描监听的连接中是否有就绪的事件,有的话交给工作线程进行读写。一般用这种方式实现C10K问题。
堆外内存(direct) 一般适合io频繁并且长期占用的内存,一般建议重复使用,只能通过Native Memory Tracking(NMT)来诊断,MappedByteBuffer可以通过FileChannel.map来创建,可以在读文件的时候少一次内核的拷贝,直接将磁盘的地址映射到用户空间,使用户感觉像操作本地内存一样,只有当发生缺页异常的时候才会触发去磁盘加载,一次只会加载要读取的数据页,例如rocketmq里一次映射1g的文件,并通过在每个数据页写1b的数据进行预热,将整个1G的文件都加载到内存。
设计模式
- 创建对象:工厂 构建 单例
- 结构型: 门面 装饰 适配器 代理
- 行为型:责任链 观察者 模版
- 封装(隐藏内部实现) 继承(代码复用) 多态(方法的重写和重载)
- 设计原则:单一指责,开关原则,里氏替换,接口分离,依赖反转
微信公众号:内核小王子 关注可了解更多关于数据库,JVM内核相关的知识; 如果你有任何疑问也可以加我pigpdong[^1]
历史文章:
JAVA和操作系统交互细节
通过MySQL存储原理来分析排序和锁
网络内核之TCP是如何发送和接收消息的