一万九千字 JVM 总结

1 运行时数据区 

1.1程序计数器

  线程私有的 程序计数器 是唯一个不会发生内存溢出的数据区  

  用来存储虚拟机正在执行字节码的地址 如果指令是本地方法 则为null

1.2 java 虚拟机栈

  线程私有的  由栈帧组成 每次方法调用都会生成一个栈帧 

  栈帧 存储的是:

  方法引用 用来指向方法区代表当前栈帧的方法 

  操作数栈 在操作数栈中执行字节码指令 

  本地变量表  

  方法返回值地址  用来存储调用此方法的上级方法地址  

1.3 本地虚拟机栈

  线程私有的 用来执行本地方法

1.4 堆

  线程共有的 用来存放类的实例对象 Java7 之后静态变量 和字符串常量池在堆里 

  标记清理就是 空闲列表

  标记整理 标记复制就是指针碰撞 或者 一个线程单独分配一块内存空间 

1.5  方法区

  线程共享  方法区在java8之后是存储在元空间 是在直接内存 用来存储 常量 ,已经被虚拟机加载的类型信息 运行时常量池  还有即时编译器 编译后的代码缓存等数据 其中运行时常量池 是对应着class文件中的常量池表   常量池表 用于存放 编译期间生成各种字面量和符号引用 这部分内容在类加载之后被放在运行时常量池中  符号引用转换为直接引用的地址也存放在 运行时常量池

1.6  直接内存

  NIO 可以使用Native函数库直接分配堆外内存 然后通过一个储存在堆中的DirectByteBuffer 对象最为这块内存的引用 进行操作 避免了 堆与内存之间的来回拷贝 提高性能

2 对象创建的大致过程

2.1加载

  当遇到new关键字时候 根据这个指令的参数在常量池中查找是否定位到一个类的符号引用

  检查这个类是否被加载解析初始化 如果没有就进行该类的加载操作  加载

  加载流程 根据当前类的全限定名找到该类的二进制字节流  根据二进制字节流在方法去生成此类的 运行时数据结构  在堆中生成一个代表该类的java.lang.Class对象 作为方法区这个类的各种数据的访问入口 详细的细节请看 2.4类加载器

2.2链接

2.2.1校验

  是对class文件的格式以及数据进行校验 避免信息中有损害虚拟机的行为 类似 不满足java书写规范的  

2.2.2准备

  准备阶段 是用来 给静态变量分配空间以及赋初始值的过程 以及final 赋值 全值

2.2.3解析

  解析阶段用来把符号引用转换为直接引用

2.2.3.1符号引用

  是以一组符号描述所引用的对象 可以是任何形式的字面量 只要使用时能无误的 定位到目标即可 符号引用与虚拟机实现的内存布局无关

2.2.3.2直接引用

  是可以直接指向目标的指针  偏移量或一个间接定位到目标的句柄  直接引用是和虚拟机内存布局相关 如果有直接引用 那引用目标一定在虚拟机的内存中存在

2.2.3.3解析分为类解析 字段解析 方法解析 接口方法解析 
类解析

  即 a类中调用b    b可能是接口或者 类 也可能是数组 ,把符号引用 c 解析为 b的直接引用

(1).  如果b 是类  那就把代表c的全限定名传递给 a类的加载器 进行加载

(2).  如果 b 是数组 数组元素的类型是 c 那就还是按照1 中的方式把c的全限定名传递给a的加载器 进行加载 接着虚拟机就会生成一个代表该数组维度和元素的数组对象 是jvm创建的哦

字段解析 

  解析一个未被解析的符号引用 先对 字段表 class_index项中索引的CONSTANT_CLASS_info符号引用进行解析 就是惊醒 1 中的类或者接口解析解析成功进行以下操作 B代表刚刚解析的类或接口 

(1).  如果B中有名字和描述都符合与目标相匹配的字段 则返回这个字段的直接引用 ,

(2).  否则就去B继承的各个接口以及这些接口的父接口中寻找符合与目标相匹配的字段 有则返回这个字段的直接引用.

(3).  否则就去B的父类中寻找符合与目标相匹配的字段 有则返回这个字段的直接引用. 

找到了还要对字段的访问权限进行检查 如果不具备对字段的访问权限则抛出异常

方法解析

  方法解析和字段解析前期步骤一样 也是先对 字段表 class_index项中索引的CONSTANT_CLASS_info符号引用进行解析 就是解析 1 中的类或者接口解析解析成功进行以下操作 B代表刚刚解析的类 (没有或接口了 因为这是解析类方法的 )

(1)  如果在类的方法表中索引的B是一个接口就抛出异常 和下面相反

(2)  和字段查找一样 都是先查找本类 在查找父类 在查找实现的各个接口以及这些接口的父接口 找简单名称和描述符都与目标相匹配的方法 找到了就返回改方法的直接引用 然后在对此方法进行权限验证看看对此方法是否有访问权限

接口方法解析

  方法解析和字段解析前期步骤一样 也是先对 字段表 class_index项中索引的CONSTANT_CLASS_info符号引用进行解析 就是解析 1 中的类或者接口解析解析成功进行以下操作 B代表刚刚解析接口(没有或类了  因为这是解析接口方法的) 

(1)  如果在接口的方法表中索引的B是一个类就抛出异常 和上面相反

(2)  类似都是现在本接口找 之后在 向父接口找 由于接口是多继承 是向各个父接口寻找 一直向上查找  简单名称和描述符都与目标相匹配的方法 在java9 之前是不用对该接口进行权限检查的因为9之前都是public的 9之后增加了 接口的静态私有方法

2.3初始化 

  初始化就是执行类构造器的<clint>()方法的过程 这个方法并不是程序员编写的 而是javac编译器的自动生成物 本质就是 编译器自动收集类中所有变量的赋值动作和静态代码块中的语句进行和并产生的`编译器收集的顺序是由源文件的顺序决定的

1.  静态代码块中只能访问到定义到静态代码块之前的变量定义在之后的只能进行赋值不能进行访问

 2.  <clint>()方法与类构造器不同 不需要调用父类的构造器 因为java虚拟机保证了 父类的<clint>方法在子类的<clint>()方法调用前执行完毕 所以java虚拟机中第一个执行的<clint>()方法的类为Objet

3  由于父类的<clint>()方法比子类先执行意味着父类的静态代码块比子类的先执行

4  对类和接口来说 这个方法也不是必须的 类中没有静态代码块 和没有对变量的复制操作 那编译器就不会为此类生成<clint>()方法

5  接口中不能使用静态代码块 但是可以有对变量赋值的操作 所以编译器也可以为接口生成<clint>()方法 但是接口中不需要先执行父类的<clint> 因为只有当使用父接口中的变量时才会导致父接口的初始化

6   jvm必须保证一个类的<clint>()方法在多线程环境下被正确的加锁使用 多线程同时初始化一个类只有一个线程能执行这个类的<clint>()方法 其他线程都需要阻塞等待 因为这个方法只能被执行一次

2.4 类加载器

  就是当前类的全限定名找到该类的二进制字节流  根据二进制字节流在方法区生成此类的 运行时数据结构  在堆中生成一个代表该类的java.lang.Class对象 作为方法区这个类的各种数据的访问入口

2.4.1类与类加载器

  对于任意的一个类 都必须由一个加载他的类加载器和这个类本身确立在jvm中的唯一性 ,

  简单来说对比两个类是否相同 只有在这个两个类被同一个类加载器 加载的前提下才有比较的意义

  即使是同一个Class文件只要是类加载器不同 这两个类就不同 

  每一个类加载器都有其独立的类名称空间 

2.4.2 双亲委派模型

  启动类加载器Bootstrap Class Loader 这个类负责加载 存放在<java_home>\lib目录 和被-Xbootstrap参数指令的路径中存放的 这个是由c++编写的

  扩展类加载器 这个类负责<java-hone>\lib\ext目录 或者被 java.ext.dirs系统变量所指定的路径中所有类库 

  应用程序类加载器 Application ClassLoader 负责加载Class Path 上所有的类库  

  双亲委派模型是当一个类加载器收到类加载请求 它首先不会自己进行类加载而是委派给上层加载器进行加载 当上层 反馈回来无法加载时自己在尝试加载

  好处就是 类在程序中各个位置加载 都能保证是同一个类加载器进行加载 确保是同一个类不会出现调用地点不同加载出来不同的类   如果没有双亲委派模型 你要是写一个和核心类同名的类  放在classpath路径下  加载之后无法分辨 你写的类和核心类 整个java体系就会乱掉 

  双亲委派机制 源码 loadclass方法 就是递归调用上层的类加载器 findclass方法就是调用自己的类加载器 进行加载

2.4.3 破坏双亲委派机制

  第一次破坏是因为双亲委派模型出现的晚了 大家都是自己重写类加载器 来保证类的一致性 

  第二次破坏是因为这个模型的缺陷 核心类要调用用户写的类 在前面类加载解析阶段说过 类的解析在 a类中调用b 就把 b交给a的类加载器进行加载     核心类要调用用户写的类  启动类加载器 只管 <java-home>\lib 目录下的类加载    用户写的类 不在这个目录下  没办法加载 因为这个双亲委派模型没办法向下委派 只能向上委派  没办法加载  java设计团队为了解决这个问题设计了一个线程上下文类加载器 这个类加载器由Thread类的 setContext-Classloader()方法进行设置 如果线程创建时候没有设置就会从父线程中继承一个 如果没有设置过 这个线程上文类加载器就 默认为应用程序类加载器    

2.5 对象的内存布局 对象头 实例数据 对齐填充

  一个对象包含三部分对象头 实例数据对齐填充

2.5.1对象头

  分为两部分 一部分是标记头 Mark word 哈希码 分代年龄  锁标志位 偏向线程id 线程持有的锁 偏向时间戳记等 

  另一个是 类型指针  指针指向方法区当前对象的类型元数据

  如果对象是数组就必须在对象头中有一块记录数组长度的数据

2.5.2实例数据

  存放代码中的定义的各种类型的字段内容 无论是父类继承的还是子类中定义的字段都必须记录下来

2.5.3对其填充

  就是为了对象整体占用的大小为 8字节的整数倍   可有可无 如果已经是八字节的整数倍 就不需要对其填充

2.6 对象的访问定位

  java程序会通过栈上的reference数据来操作堆上的具体对象  jvm规范中规定 reference是一个指向对象的引用 没有规定是通过什么方式去定位. 访问堆中对象的具体位置 主流的两种访问方式 使用句柄访问 和直接指针 

2.6.1 句柄访问 

  java堆中划分一份内存空间 作为句柄池 referce中存储的是对象的句柄池的地址 句柄池中包含这个对象的实例数据(堆)与类型数据(方法区)各自的地址信息 一个对象一份句柄池 

2.6.2 直接指针

  是referce中直接存储对象地址  但是设计这个对象时候就必须考虑如何放置可以访问该类型数据的相关信息 (上面说的对象头的第二部分 类元指针)

  HotSpot 虚拟机使用的直接指针的访问方式

2.7 小试牛刀内存溢出

  堆内存溢出就是实例对象创建太多了 OutofMemoryError   java heap space

  栈内存溢出 一种是递归调用 线程请求的栈深度大于虚拟机允许的最大深度 抛StackOverflowError

  如果虚拟机的栈内存允许动态扩展 当扩展栈容量无法申请到足够的内存时抛 OutofMemoryError

  方法区内存溢出 就是加载一大堆类 OutofMemoryErro  8之前就是 永久代 PermGen space 之后就是元空间Mate space

  本机直接溢出 直接内存  默认与堆最大值一致 可以通过参数设置 

3 垃圾收集器与内存分配

3.1 引用计数法

在对象中添加一个引用计数器 每当有一个人引用这个对象时候 这个计数器加一 引用失效就减一      当计数器为0的时候对象就是不能被使用的 但是会出现循环引用的情况 a对象引用b b对象又引用这a 会导致一直都无法被回收

3.2 可达性分析算法

  沿着GC roots对象的引用链一直向下搜索 没有被引用到的就称为不可达 即可回收对象  (根据引用关系向下搜索 搜索走过的路径叫引用链)

  哪些对象可以作为根节点 

(1) 栈帧中局部表中引用的对象  堆栈中使用到的参数 局部变量 

(2) 在方法区中静态属性属性引用的对象 Java类的 引用类型静态变量 

(3)  异常对象 运行时异常 和内存溢出异常 等等 

(4) 被同步锁(synchronized)持有的对象

等等等等 

3.3 脑残玩法

  在回收对象时候会分析这个对象是否重写了 finalize方法 如果重写了在回收时候会进行执行 假如在执行的时候  只要重新和引用链上的对象建立关联 把this 赋值给某个类变量或者对象的成员变量 就不会被回收 这个只能执行一次 

3.4 强软弱虚 四种引用 

强引用

  就是两个对象之间直接引用  new关键字  只要强引用关系在 就不会被垃圾回收器回收

软引用 

  SoftReference类来实现 在内存不足时候会被垃圾回收器回收  new SoftReference<>(new A)

弱引用

  WeakReference 当垃圾收集器开始工作时候就会被回收 new WeakReference<>( new A)

虚引用 

  PhantomReference 必须配合引用队列(ReferenceQueue)使用 因为虚引用只有一个构造方法必须传入一个引用队列   任何情况下都可能被垃圾回收器回收  主要作用是跟踪对象被垃圾回收的状态 并在所引用的对象内存被回收之前采取必要的行动 如果一个对象仅持有虚引用 那他就和没有任何引用一样 GET 方法无法获得对象 返回值为null

A=new  ReferenceQueue<B>();

new  PhantomReference<>( new B,A);

  回收流程 在垃圾回收时候会把引用实例(上个面创建的 B类的实例对象)放进引用队列  然后把引用实例从引用队列取出来 断开虚引用和引用对象的关系后 才会被垃圾回收器回收

3.5 脑子有问题回收方法区 

  主要就是回收废弃的常量 和不在使用的类型 

  废弃的常量 加入一个字符串"java" 曾静进入常量池 已经没有任何一个字符串对象引用这个"java"常量 也没有任何一个地方引用这个字面量 这个java这个常量就会被清除常量池

  类回收 这个类的实例都被回收了 加载该类的类加载器也被回收了 该类的 java.lang.Class对象没有在任何地方被引用 无法在任何地方通过反射访问该类的方法

3.6垃圾收集算法

3.6.1分代收集理论 

建立在两个假说上1 . 弱分代假说 ;大多数对象都是朝生夕死;

                                2.强分代假说 ; 熬过了越多次垃圾回收过程的对象就越难消亡;

 为了解决新生代被老年代引用避免把所有老年代全部轮训一遍 增加了一个假说

                                3 跨代引用假说: 跨代引用相对于 同代引用来说仅占极少数

3.6.2标记清除算法

  算法分为两个阶段先标记 在进行清除 

  主要缺点就是 标记清除导致空间碎片化 可能导致相邻的内存空间没办法放下较大的对象而导致提前进行一次垃圾回收  导致吞吐量下降 是因为碎片化空间 导致内存的分配与访问时间边长 所以会导致吞吐量下降   标记清理是空闲列表来解决分配内存的问题    但是优点就是响应速度快 如果清除的对象过多会导致执行效率低 就有下面这种算法

3.6.3标记复制算法

  算法分为两个阶段 先标记存活的 再把存活的复制到准备好的区域 再把当前区域清空  用指针碰撞来解决内存分配问题 但是在存活对象较多的情况下进行较多的复制效率会降低 就出现下面这个算法

3.6.4标记整理算法 

  这个算法的标记过程和标记清理算法一样都是先标记 但是后续不是清理而是把存活的对象移动到一侧然后清理掉边界外的内存  但是你多了一个移动对象的动作增加了耗时 会stop the word    会影响响应时间 但是会有很好的吞吐量       用指针碰撞来解决内存分配问题

  总结 标记复制是解决大批量对象死去的场景 标记整理是解决大批量对象存活的  标记清除也能解决大批量存活 结合 上面的假说 我们可以得到哪种算法适应哪种分代的问题 标记复制适用于新生代的垃圾回收  标记整理标记清楚适用于老年代的垃圾回收  老年代如何选择那  要吞吐量老年代用标记整理 要响应速度用标记清除 

3.7HotSpot的算法细节实现

根节点枚举

  就是查找gcroot对象的过程  此过程是stop the word 所以不能浪费太多时间 在hotspot的解决方案就是 使用一组称为Oopmap(普通对象指针map) 来解决这个问题

一旦类加载动作完成 hotspot就会把对象内偏移量上是什么类型的数据计算出来 在即时编译过程中 也会在特定位置记录下栈里和寄存器里哪些位置是引用 这样收集器在扫描时候就可以直接知道这些信息了并不需要真正一个不漏的从方法区等Gc Root开始查找

安全点 

  就是为了完成快速的根节点枚举 但是不能每一条指令都对应生成oopmap 这样内存消耗太大了  即时编译在特定位置记录下栈里和寄存器里哪些位置是引用 这个特定点就称为安全点  规定线程只有运行到安全点才可以进行垃圾回收  这样安全点设置太少了 线程等待时间就过长了 安全点太少了 又会导致额外内存占用太多 安全点的选取 是以 是否具有让程序长时间执行的的特征 为标准选定的长时间执行最明显的特征就是指令序列的复用 例如方法调用 循环跳转 异常跳转

  对应安全点考虑一个问题如何在垃圾回收发生时所有线程都跑到安全点中断下来 两种 一种为抢先式中断 一种为主动中断

  抢先式中段 系统把用户线程全终端如何这个线程没执行到安全点就放行到安全点在中断 几乎没有虚拟机用这种

  主动中断  当垃圾收集器需要中断线程时 仅仅是对线程设置一个标记位 各个线程会一直轮询这个标志 一旦发下标志为真时候就在自己最近的安全点挂起 轮询的地方和安全点是重合的

安全区域

  安全区是为了那些没法运行的线程 sleep 或者blocked状态的线程没办法走到安全点挂起自己

  安全区域 是指能够确保在某一段代码片段中 引用关系不会生改变 因此在这个区域任意地方开始垃圾收集都是安全的 当用户线程执行到安全区域里面的代码时候 会标记自己进了安全区域  那样在垃圾回收器开始工作时候就不用管这些线程 当线程要走出这个安全区域的代码时候要检查根节点枚举是否结束 结束就没事发生继续执行 没结束 就必须一直等待 直到收到可以离开安全区域的信号为止

记忆集与卡表

  记忆集是抽象的想法 用来记录跨代引用

  卡表是记忆集的实现 用来记录一个内存区域中是否存在跨代引用

  卡表最简单的实现方式是用字节数组  hotspot也是这样实现的 数组每个元素代表一个内存区域是否存在内存引用   这样划分的每个小内存块被称作卡页 通常来说卡页大小为 2的n次幂的字节数 Hotspot 为2的9次幂字节大小 即512字节  如果划分的地方存在跨代引用就把相对应的数组元素改为1 在垃圾回收时候 就遍历这些变脏的元素  所对应的内存块  把他们放进 GC roots中一并扫描 就不用遍历整个老年代了 

写屏障(类似aop在方法调用前后执行)

  解决卡表维护与何时变脏 谁来变脏 的事情 

  何时变脏 应该在引用赋值那一刻就应该变脏 

  但是如何变脏 如果是解释执行 解释型编译器好办因为jvm自己 进行字节码执行的动作 可以在 引用赋值字节码上下 添加一个字节码指令即可 

  但是在 编译执行 即时编译器直接翻译成存粹的机器指令流了 这样只能找到一个机器码层面的手段 把维护卡表的动作放在每一个赋值操作中   

这就必须要用到写屏障了 即 在引用对象赋值时会产生一个环形通知 供程序执行额外动作 在引用执行之前叫写前屏障 在引用执行后叫写后屏障  在G1收集器出现之前所有收集器用的都是写后屏障 

每次引用都会更新卡表 产生额外开销 不过这个开销与MinorGC扫描整个老年代 的代价相比还是小得多

  除了写屏障开销 卡表还存在一个问题就是 伪共享 因为处理器在读取数据最小单位为 缓存行 一个缓存行 假设为  64字节 假设一个卡表占用一个字节  当A线程 处理第一个卡表进行涂脏(把里面元素变为1)   你想修改 第一个卡表 但是处理器读取数据时候 不会只读取你要修改的卡表  而是会读取 一个缓存行 即 64个字节 的内存数据     就有可能把与这个卡表 内存上挨着的其他63个卡表也读了进来     这时候B线程 要修改被读进去的第二个卡表 修改的比你块 你就得在重新从主内存中读取   因为你手里拿的是已经失效别人修改过的数据了  即使那个数据你没有要修改  

之后的java7 添加了一个参数 来决定是否开启卡表更新的条件判断 开启这个条件 会多一次判断开销 每次进行更新卡表前先判断 卡表该位置是否已经被修改为脏卡 没有被修改 就读取进行修改                                

栈指令集 寄存器指令集 

并发的可达性分析

  在并发状态下 对象的引用会一直改变 增加引用 或者取消引用 这样判断对象可不可达就不太 准确

但是又不能让所有线程都停下来 进行可达性分析 因为这样的耗时比 根节点枚举时间长的多的多 

如果是第二种取消引用 还好 大不了这个对象下一轮在被回收

  在已经分析可达的(A)对象新增了 一个对象引用(B)  恰好这个(B)对象被正在检查是否为可达对象对象(C)取消引用那这个B对象就会被标记为不可达 因为没有在扫描的路径出现过 即使B被A引用   这样就坏事了 

  (1)赋值器插入一条或者多条从黑色对象到白色对象的引用

  (2)赋值器删除了全部从灰色对象到白色对象的引用或间接引用 

  只有当这两种情况同时出现在一个对象身上时候才会出现不应该被 回收的对象被回收  所以选择一种条件进行破坏即可 

   emm这样 的话 就是要么记录 黑色对象增加了 哪些引用 要不就是 记录 灰色对象删除了哪些引用  再根记录做出相应的优化即可解决  并发的可达性问题 

  记录黑色对象增加了什么引用的方法叫做增量更新 CMS 并发标记收集器 在并发标记阶段用的是 增量更新    这个就是根据记录把黑色对象沿着引用链在遍历一遍  这次就能沿着引用链从A找到B 

  记录灰色删除对白色对象间接引用或直接引用  叫原始快照 G1 用的是这个   这个是记录C到B的引用 这个用到的是

写前屏障

  在引用断开前 将这个删除记录 记录下来 这个就是 上面说的G1 用到的写前屏障 原始快照

引用文章https://www.cnblogs.com/meixiaoyu/articles/16669367.html

3.8经典垃圾回收器

3.8.1 单线程收集器 Serial 

  暂停所有线程 开一个线程进行垃圾回收s 新生代标记复制 运行   之后在停止所有 开一个线程进行垃圾回收 老年代 标记整理 

3.8.2并行收集器 Parallel 吞吐量优先 

  所有线性同时运行 所有线程一起开始垃圾回收 新生代 标记复制  在所有同时运行 在所有同时垃圾回收  老年代 标记整理

注重吞吐量用标记整理 

3.8.3并发收集器CMS   

  初始标记 是标记GCroots对象需要STW 并发标记 (单开一个线程进行标记 ) 重新标记 就是进行上面所说的 并发可达性解决办法 CMS 用的是增量更新 这个阶段就是做这个事情  把增量更新里面记录增加的引用  黑色对象转换为灰色在沿着引用链遍历  并发清除就是单开一个线程进行清除操作

优点

  响应速度快 耗时的操作都放在并发阶段进行 

缺点

  (1)  吞吐量小    由于老年代用的是 标记清楚算法 响应时间会变快 但是 会导致内存碎片 导致吞吐量下降 因为内存的分配和访问受影响 而内存的分配和访问 占需求的比重比较大 还有导致吞吐量下降的原因是 在并发标记 并发清除时候总有线程被占用 用来做垃圾回收   整体下来 CMS吞吐量就比较小 

  (2)   无法处理浮动垃圾  因为 在并发标记和并发清理阶段 系统还正在运行 而且这段时间还比较长 就会产生比较多  浮动垃圾   浮动垃圾是在 标记过程结束后 产生 没办法清理出去 只能等到下一次垃圾回收 在收掉

(3) 因为标记清除算法 导致老年代内存碎片问题当遇到大对象要放入老年代时候 没有连续的大内存能放下 只能 提前进行一次 FullGC 有个优化参数就是要求 cms收集器在进行若干次不整理内存的Full  GC 之后下一次 Full GC 前 会先进行碎整理 

3.8.4Garbage First收集器

他和前几个不太一样 他进行垃圾回收的时候 不是整个新生代或者整个老年代这样收集 而是按照一个内存块 一个内存块收集     G1收集器把内存分为若干个名字为Region的小块  回收时候会看哪个Region的回收价值高  回收价值是 可以回收多少内存  和花费的时间 来排名这个价值 先回收价值高的  G1也是基于分代理论的  每个Region不存在不同代分区 即 这个region 要么被视为 新生代要么被视为老年代  新生代采用标记复制 老年代标记整理  当对象大小 超过region 一般时候 就会被判定为大对象 会被放在 特殊的Humongous区域   当对象大小超过 Region 是 会被放在N个连续的Humongous Region中 当作老年代的一部分看待  

G1 为每个Region设计了两个名为 TAMS(Top at Mark Start)的指针 把Region中的一部分空间划分出来用于并发回收过程中的新对象分配并发回收时候 新分配的对象必须在这两个指针的位置上  G1收集器 默认在这地址以上的对象是被隐式标记过 不会被回收 

垃圾回收四个阶段 初始修改 并发标记 最终标记 筛选回收 

和上面 CMS 类似 但是这个 只有一个并发标记是并发执行的 其他三个阶段都需要 STW

初始标记 还是标记一下 GC Roots能关联到的对象 并修改 Rengion 中TAMS的值 让接下来 并发执行的时候  可以正确的在可用的 Rengion中分配新对象 

并发标记 开始从GC Roots对象开始进行可达性分析  在处理下 SATB记录下的并发时候变动的对象 

最终标记 STW 用于处理并发结束阶段后 仍遗留下来的最后少量的SATB记录 

筛选执行 负责更新Region的统计数据 对各个Region 的回收价值成本和成本进行排序 根据用户的预期暂停时间来制定回收计划 可以选择多个Region 构成回收集 然后吧这些Region的存活对象复制到空 Region 在清理掉 整个旧Region空间  这一步是 STW 需要多条收集线程一起并行完成的

用户可以制定暂停时间 这个是G1的亮点之一 在吞吐量和 延迟之间 既可以找到平衡 又可以创造某一种优势   

G1与CMS比较 优缺点

G1可以定制暂停时间比较灵活 既可以高吞吐又可以低延迟 

大体上看 G1属于标记整理 但是 在局部来看  两个Region之间又是标记复制 这两种的好处就是不会产生内存碎片 

CMS 优点就是  内存占用小 卡表有且只有一个只需要考虑老年代对新生代的引用 

而 G1 每个Region 都有一个卡表  

CMS 重新标记阶段的耗时比较长 因为 使用到增量更新 需要重新 从增加引用的对象在进行一次可达性分析 

G1 使用写前屏障 来实现 原始快照搜索算法(SATB)     需要使用写前屏障来跟踪并发时指针的变化  在用户程序运行时候会产生由跟踪引用变化带来的负担  由于G1对写屏障的复杂操作 要比CMS消耗更多的运算资源 所以 CMS 的写屏障实现是同步操作 G1就不得不将其实现为类似消息队列的结构 把写前屏障和写后屏障要做的事都放入队列中 在异步进行处理

3.9内存分配与回收

对象优先分配到Eden区 

大对象直接进入老年代 

长期存活的对象进入到老年代 过了设置的阈值

动态对象年龄判断Survivor(幸存者区   分为from区和to区 一样大小 )  当这个区域相同年龄的 对象大小占总幸存者区一半 那大于等于这个年龄的的就晋升到 老年代  

空间分配担保 发生Mionr GC之前 jvm检查老年代剩余连续空间 是否大于新生代所有对象的和 如果成立 这次 Mionr GC 可以确保 是安全的 不成立 就得看看参数中 有没有`设置允许担保失败的参数

如果设置了 就要看看最大剩余连续空间 是否大于历届晋升 到老年代对象的平均大小 

如果大于 就尝试进行Mionr GC

如果没设置 或者小于历届就进行Full GC

4 虚拟机字节码执行引擎

概述 代码编译的结果从本地机器码转变为字节码 是储存格式的一小步却是编程语言的一大步

4.1  执行引擎 两种 一种是解释执行 一种是编译执行 

在执行引擎执行字节码时候可以选择上面这两种任意一种 也可以两个都有 

外观来看jvm的执行引擎输入输都一致  输入的是字节码二进制流 处理过程是字节码解析执行的等效过程  输出的是执行结果

4.2 运行时栈帧结构  

jvm以方法作为最基本的执行单元  栈帧存储这 局部变量变 操作数栈 动态链接 方法返回地址 和一些额外的附加信息 在编译java程序源码时候 一个栈帧中 需要多大局部变量表   多深的操作数栈  都已经被分析出来了  位于栈顶的被称为 当前栈帧 这个栈帧关联的方法为当前方法  执行引擎所运行的字节码指令 都只对当前栈帧进行操作 

4.2.1 局部变量表 

是一组变量值的储存空间   用于存放方法参数 和方法内部定义的局部变量  在java程序被编译为Class文件时 就在方法的Code属性的max_locals数据项中明确了该方法所需分配的局部变量表的最大容量 局部变量表的容量 以变量槽为最小单位  jvm通过索引定位方式使用局部变量表 从索引值范围为零开始到局部变量表最大的变量槽 数量 如果执行的是实例方法(没有被 static修饰的方法 )

那局部变量表第一个 第零号索引 的变量槽默认是用于传递方法所属对象实例的引用 在方法中通过关键字 this 来访问到这个隐形的参数 其余参数按照参数表顺序排列 占用 从一开始的局部变量槽 局部变量表的变量槽可以复用  

4.2.2 操作数栈

在java程序被编译为Class文件时 就在方法的Code属性的max_stacks数据项中明确了该方法所需分配的操作数栈的最大深度 jvm解释执行引擎被称为基于栈的执行引擎 这里的栈是操作数栈 

4.2.3 动态链接 

指向运行时常量池中该栈帧所属方法的引用 持有这个引用是为了 支持方法调用过程中的动态链接

在类加载时候 会把 符号引用转换为直接引用 这些能直接转化的引用 被称为静态解析  有一部分 在每次运行期间在转化为直接引用 这部分就称为动态链接 

4.2.4方法返回地址

 方法执行后 两种退出方式 一种是执行到 方法返回的字节码指令 另一个是在方法执行过程中遇到了异常 无论哪种方式推出 都必须返回到方法被调用的位置  一般方法退出主调方法的程序计数器 的值可以作为返回地址  栈帧中 有可能 保存这个计数器值 当方法异常退出 返回地址要通过异常处理器表 来确认 栈帧中 一般灰灰保存这部分信息 

方法推出的过程等于当前栈帧出栈 可能进行的操作有 恢复上层 局部变量表和操作数栈 把返回值(如果有 ) 压入 调用者 操作数栈 调整pc计数器的值和指向方法调用指令后面的一条指令 

4.3方法调用

确定方法调用 的版本(即调用哪一个方法 可能 该方法被重载或者被重写了 要确定下来)

4.3.1解析 解析 就是在编译期间能确定 调用方法的版本的 (类加载阶段 解析阶段 会把 符号引用转换为直接引用  这种编译期间能确定下来的方法  这种方法的调用被称为解析)   这种 方法 有两大类 静态方法 和私有方法 一种是和类相关 一种是 私有的 外部 无法访问 这两种 方法 无法重写 静态可以重载 (方法名 相同 参数数量 或者类型 不同 )

4.4 调用不同类型的方法 字节码指令分别为 

invokestatic 调用静态方法

invokespecial 调用实例构造<init>()方法 ,私有方法和父类中的方法 

invokevirtual 调用所有的虚方法

invokeinterface 调用接口方法 会在运行时 在确定一个实现该接口的对象 

invokedynamic 现在运行时动态解析出调用点限定符所引用的方法 然后在执行该方法  

前面四条调用指令 分派逻辑 都是在虚拟机内部 而最后一个指令 的 分派逻辑是由 用户设定的引导方法来决定的  

被前两个指令调用的方法 都可以在解析阶段确定唯一点用的版本  这里两个指令调用 静态方法  私有方法 实例构造器 父类构造器 以及 被final修饰的方法(这个被invokevirtual指令调用)  这些方法被称为非虚方法 其余被称为虚方法 

 解析调用 就是在编译时期就能确定调用方法版本 (是一种静态过程 )

分派调用 就是不能再 编译期间确定调用方法版本的 (即有静态, 又有动态 还根据分派基于多少种宗量的数量分为单分派和多分派 )

4.4.1静态分派

(例子重载 )所有依赖参数静态类型 (编译期间确定的类型) 来决定方法执行版本的分派动作被称为静态分派 

4.4.2动态分派

(例子重写 )

所有依赖动态类型 (运行期间确定的类型 运行类型 ) 来决定方法执行版本的分派动作被称为动态分派  invokervirtual指令 运行时 解析过程 分为四步 `

1找到操作数栈栈顶的第一个元素所指向对象的实际类型(运行类型 ) 记作c

2 如果在类c中找到与常量中描述符和简单名称 都相符的方法 则进行 权限校验 如果通过则返回这个方法的直接引用 不通过校验就返回异常 

3 否则就按照继承顺序从上到下 依次对c的各个父类进行二次搜素和验证

4 找不到就抛出异常  

4.4.3单分派和多分派

方法的接收者(一个方法所属的对象) 和方法的参数被称为方法的宗量   分派基于多少种宗量 被分为 单分派和多分派 

根据一个宗量对目标方法进行选择的是但分派 反之 根据多宗量 对目标方法进行选择被称为 多分派   

静态分派 为多分派 不仅通过方法接收者静态类型(编译类型) 还要根据 方法参数静态类型(编译类型) 两个宗量确定 

动态分派为单分派 因为 通过方法接收者实际类型(运行类型) 就能决定调用方法版本(看上面动态分派 )

java 默认方法调用的都是 invokervirtual

4.4.4JVM动态分派实现

优化就是建立一个虚方法表(表中存放各个方法的实机入口) 如果虚方法表中 子类没有重写父类的方法(相同方法地址一致) 那就指向父类实现的入口 重写就在子类虚方法表中 替换为子类实现方法的实际入口 

4.5动态类型语言

动态类型语言的特征就是 类型检查主体发生在 运行时期 而不是编译时期  ]

运行时期 就是代码执行到这一步有问题在报错   

编译时期   就是不管执行不执行到这一行 你写的要是有毛病就会报错 

类型检查   动态非动态语言 区别 编译类型 非动态 在编译器期间 确定 动态语言在运行期间确定   java在编译期间就将引用方法 完整的符号引用生成出来 并作为方法调用指令的参数储存在 Class文件中 

这个符号引用包含了 这个方法定义在哪个具体类型之中 方法名字 以及参数顺序 参数类型返回值等信息 通过这个符号引用 虚拟机就可以翻译为直接引用   而动态类型语言 在编译时期最多只能确定 方法名称参数 返回值 不会确定 方法所在的具体类型(既 接收者不确定 )  变量无类型 而变量值才有类型 (我的理解就是  B 继承A   A a = new B     a 没有编译类型   a 有 运行类型 等到运行期间 才知道 a的运行类型是 B )

invoke包与invokedynamic 没懂 不理解蒙掉了

4.6基于栈的字节码解释执行引擎

jvm的执行引擎在执行java代码时候 有解释执行 和编译执行 

4.6.1 Java编译

java编译器完成了 代码语法分析 词法分析 抽象语法树 遍历语法树生成 线性的字节码指令流的过程 这个部分在JVM之外进行的 解释器在JVM内部 所以Java程序的编译就是半独立的实现

4.6.2基于栈的指令集和基于寄存器的指令集

javac编译器输出的字节码文件指令流 基本上是一种基于栈的指令集架构 字节码指令流里面的指令大部分是零地址 指令 依赖操作数栈进行工作  另一套指令流架构 是基于寄存器的指令集 就是pc机中物理硬件直接支持的指令集架构 

两个区别有 基于操作数栈的指令集 这种指令流中的指令通常是不带参数的 使用操作数栈中的数据作为指令的运算输入 指令的运算结果 也是存储在操作数栈中 

寄存器中是把寄存器中的数据进行更改  每个指令都包含两个单独的输入参数 依赖进存起来访问和储存数据 

基于栈的指令集 优点在于可移植性  代码紧凑 字节码每个字节就对应一条指令  编译器实现更加简单 不需要考虑空间分配的问题 所需空间都在栈上操作 

基于栈的缺点  是理论上 执行速度 相对来说速度会慢一点  解释执行时候 栈架构的指令数量会比较多 因为入栈出栈操作本身就产生了相当多的指令 再加上栈是在内存上 频繁地栈访问  就是频繁地内存访问 对于处理器来说 内存执行始终是 执行速度的瓶颈 

5 编译优化

6 java内存模型

主要目的是定义程序中各种变量的访问规则 

6.1 主内存与工作内存

java内存模型规定了 所有变量都储存在主内存中 每条线程都还有自己的工作内存 线程的工作内存中保存着被该线程使用的变量副本 线程对变量的所有操作都应该在内存中  不能直接读写主内存中的数据 每个线程中的变量无法直接相互访问 线程之间变量值的传递均需要痛过主内存来完成.

6.2 对于 volatil型变量的特殊规则

可见性

当一条线程修改这个值的时候 必须立即刷回主线程 并告知 持有这个变量的其他线程需要去主内存 读取最新值

每次使用这个值的时候都必须从主内存中读取最新的 

由于volatil 只能保障可见性 在不符合以下两个规则时候仍然需要 进行加锁 来保证原子性

运算结果并不依赖变量当前值 或者能够确保只有单一的线程修改变量的值

变量不需要与其他的状态变量共同参与不变的约束 

有序性

禁止指令重排序优化 

 6.3  原子性可见性有序性

java内存模型围绕着 在并发过程中 如何处理这三个特性来建立的

原子性

java内存模型保证原子性变量操作 包括 read load assign use store write 这六个操作

基本数据访问都是具备原子性的 如果需要大范围原子性就得 加锁 

可见性 

java内存模型是通过在变量修改后将新值同步回到主内存 在变量读取之前从主内存刷新变量值 这种依赖主内存作为传播媒介的方式来实现

volatile这个保证了值修改能立刻刷新到主内存 普通变量不能立即刷回 每次使用前立即从主内存刷新 volatile 保证了多线程操作变量的可见性 

synchronized和final也能实现可见性 

synchronized 是因为规定在 unlook之前必须执行完 store write 操作 必须把此变量同步到主内存中 

final 修饰的字段在构造器中一旦初始化完成 构造器没有把this的引用传递过去 在其他线程中都可以看到final字段的值

有序性

如果在本线程中观察所有操作都是有序的 如果在一个线程中观察另一个线程 所有操作都是无序的

前半句为 线程内表现为串行的语义 后半句是指指令重排序现象和工作内存主内存同步延迟现象

synchronized和volatile 两个关键字来保持有序 

前一个是加锁使一个方法同一时间只能有一个线程来执行 决定了 持有同一个锁的两个同步块只能串行地进入

第二个是天生就禁止指令重排序

6.4先行发生原则 

这个怎么总结那  这哥们总结的好 看这个 

Java 先行发生原则-CSDN博客

6.5java与线程 

6.5 线程的实现

线程是比 进程更轻量级的调度执行单位 线程的引入可以把进程的资源分配与执行调度分开 各个线程既可以分享进程资源又可以独立调度  线程是java进行处理器资源调度的最基本单位 

主流操作系统都提供了线程的实现  而java语言提供了 在不同硬件和操作系统平台下对线程操作的统一处理 

线程的实现主要有三种方式

1使用内核线程实现   1:1

2 使用用户线程实现 1:n

3混合实现 n:m

6.6 使用内核线程实现 

内核线程(KLT)就是直接由操作系统内核支持的线程 这种线程 由内核来完成 线程切换 内核通过处理操纵调度器对线程进行调度 将线程的任务仍射到各个处理器上 每个内核线程都可以视作内核的一个分身 这样操作系统就有能力同时处理多件事情 支持多线程的内核被称为多线程内核  

程序一般不会直接使用内核线程 而是使用内核线程提供的一种高级接口 "轻量化进程(LWP)" 轻量化进程就是我们所说的线程 每个轻量化进程都由一个内核线程支持 因此只有先支持内核线程 才能有 轻量化进程 这种1:1 的关系 称为一对一线程模型 

由于各种对线程创操作 如 创建 同步 都需要系统调用 而系统调用 的代价比较高需要在用户态和内核态中来回切换 每个LWP都需要KLT的支持因此轻量化 进程需要消耗一定的内核资源 (内核线程的栈空间 )  因此一个系统支持轻量级进程的数量是有限的

6.7使用用户线程实现 

广义讲只要这个线程不是内核线程就是用户线程(但是那又包含上面讲的轻量级进程 但是轻量化进程的实现始终是建立在内核上  许多操作都需要进行系统调用并没有 用户线程的优点)

狭义上讲 用户线程是完全创建在用户空间的线程库上 系统内核不能感知用户线程的存在以及如何实现和用户线程建立同步销毁 完全是在用户态完成 不需要内核的扮装 如果程序实现得当这种线程不需要切换到内核形态 因此这个操作是非常 快速且低消耗的  也能支持规模更大的线程数量 部分高性能数据库用的就是用户线程实现的 这种进程与永华线程之间1 1:n的关系称为 一对多线程模型   

用户线程优势在于 不需要系统内核支援 劣势也在于没有 系统内核支援所有的线程操作都需要 由用户程序自己去处理 线程的创建 销毁切换调度都是用户需要考虑的问题 由于操作系统只把处理器资源分配到进程  诸如 阻塞处理 这些问题解决起来将会异常困难 甚至有些不可能实现 

6.8 使用混合实现 

就是将上述两种实现混合到一起进行实现 既存在用户线程 又存在轻量化进程  用户线程还是建立在 用户空间中  因此用户线程的创建切换 等操作依然廉价 并且支持大规模并发  而操作系统支持的轻量级进程则作为用户线程与内核线程之间的桥梁 这样使得可以使用内核提供的线程调度功能 以及处理器映射 并且用户线程的系统调度要通过轻量化进程来完成 这大大降低了 整个进程被堵塞的风险  

6.9java线程实现

操作系统支持什么样的线程模型 很大程度上会影响java虚拟机的线程是怎样映射的  线程模型只对线程的并发模型和操作成本产生影响 对于java程序的编码和运行过程来说 这些差异都是完全透明的

6.10 java程序调度

线程调度是为线程分配 处理器 使用权的过程 调度主要方式为两种 分别为 协同式线程调度 和抢占式线程调度 

协同式  线程执行时间由线程自己把握  线程执行完就要通知系统 切换到另一个线程上去 好处是实现简单 切换操作是可知的 因为是线程自己通知系统可以切换执行其他线程 一般没有线程同步问题

坏处就是线程执行时间不可控 万一代码编写有问题 一直不告知系统切换线程就会一直堵塞在那里 

抢占式 每个线程执行时间由系统来分配执行时间 线程切换不由线程本身决定 我们可以建议操作系统给某些线程多分配点执行时间  这项操作是通过 设置线程优先级 来完成的 java语言一共设置了十个优先级  当两个线程同时处于 Ready 状态时候 优先级越高 的线程 越容易被系统执行  容易而不是一定 原因就是 当系统发i西安一个线程被执行的特别频繁的时候 可能会越过优先级去为它分配 执行时间从而减少频繁线程切换带来的性能损耗

6.11状态切换 

java定义了线程的六种状态 在任意时刻线程只能有一种状态 新建 运行 无限等待 限期等待 阻塞 退出 

6.12java与协程

6.12.1内核线程的局限

1:1内核线程是现在jvm线程实现的主流选择 但是这种 映射操作系统上的线程天然的缺陷就是 切换调度成本过高 系统 容纳线程数也有限 

6.12.2 为啥线程切换调度成本比较高 

内核线程的调度成本 主要就是 用户态到核心态之前的状态切换 这两种状态切换的开销主要来自响应中断 ,保护和恢复执行现场的成本 

     线程a -> 线程中断  ->线程b 

处理器要去执行线程a的程序代码时候 并不是有代码就能跑起来 程序是代码和数据的组合体 代码执行时必须要有线程上下文 数据的支撑 这里的"上下文 "以程序员的角度是方法调用过程中各种局部变量和资源 线程角度看 是方法的调用栈中储存的各种信息 而以操作系统和硬件的角度看则是储存在内存,缓存和寄存器中的一个个数据 物理硬件的各种储存设备和寄存器是被 操作系统内所有线程共享的资源 

中断发生 操作系统需要先将线程a的上下文数据妥善保管 然后把寄存器 内存分页 等恢复到b线程挂起时候的状态这样b被重新激活后才能仿佛从来没有被挂起过 这种保护和回复避免不了 射击一系列 数据在各种寄存器,缓存中的来回拷贝 当然不是一种轻量级的操作 

7 线程安全与锁优化

7.1线程安全

当多个线程同时访问一个对象时 如果不用考虑这些线程在运行时环境下的调度和交替执行 也不需要进行额外的`同步或者在调用方 进行任何其他的协调操作 调用这个对象的行为都可以获取正确的 结果 那就称这个对象是线程安全的 

java语言中的线程安全   将线程安全程度 从强到弱 分为五个类 :不可变 绝对线程安全 相对线程安全 线程兼容 和线程独立 

不可变 final 修饰的基本数据类型 String对象,数值包装类  

绝对线程安全 不管运行时环境如何 调用者都不需要 任何额外的同步措施 

相对线程安全 就是我们通常意义上的线程安全 保证对这个对象单次 操作是线程安全的 调用时候 不需要 进行额外的同步手段 来保证线程安全  Vector HashTable 

线程兼容 对象本身不是线程安全的 但是可以通过 在调用 端正确地使用同步手段 来保证对象在并发环境中 可以安全地使用 arraylist  hashmap

 线程对立 即使用了同步手段还是在多线程中无法保证在并发环境下使用 

7.2线程安全的实现方法 

1 互斥同步 (悲观锁)  synchrinized   ReentrantLock

synchrinized  经过javac编译后会在同步代码块前后分别生成monitorenter 和monitorexit这两个字节码指令  这两个字节码指令都需要 一个 reference类型的参数来指明要锁定和解锁的对象 如果java源码中synchronized明确指定了对象参数 那就以这个对象的引用作为reference 如果没有明确指定就根据synchronized 修饰的方法类型(实例方法 或者类方法) 来决定 是取代码所在的对象实例还是取 类型对应的Class对象作为线程要持有的锁 

jvm在执行 monitorenter指令时首先要尝试获取对象的锁 如果这个对象没有被锁定 或者当前线程已经持有了那个对象的锁 那就把锁计数器加一 在执行monitorexit指令时 会将锁计数器减一 一旦计数器的值为零 锁随机被释放 如果获取锁失败 那当前线程就应该被阻塞等待 直到请求锁定的对象,被持有它的线程释放 

ReentrantLock 相对与 synchrinized 相当于增强了 有了些高级功能 

是公平锁选项

等待可中断 当持有锁的线程长期不释放锁时候 正在等待的线程可以选择 放弃等待 

锁绑定多个条件 

2非阻塞同步 (乐观锁 )

互斥同步 进行线程阻塞唤醒 带来的性能开销比较大

原子类 cas操作 比较并交换 

3 无同步方案  

可重入代码 又称纯代码 这类代码可以在任意时刻中断 转而 执行另一端代码  返回后 原来的程序不会出现任何的问题  这种代码 的共同特征 不依赖全局变量 储存在堆上的数据 和公用的 系统资源 用到的状态量都有参数传入 不调用非可重入代码 

线程本地存储 代码中所需要的数据  必须要共享给其他片段的代码 那就让他们在一个线程内  这样就能保证把这部分共享数据的可见范围在这个线程内 这样就不需要同步就能保持线程安全 

如果一个变量被多个线程访问 可以使用volatile关键字 保证线程安全 

如果一个变量只要被某个线程 独享 就可以使用java.long.ThreadLocal 类来实现 线程本地存储 

每个Thread 对象 都有一个 ThreadLocalMap对象 这个对象 存储了一组以 threadlocal哈希值为key 以本地线程变量为值的K-V键值对 ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口 

7.3锁优化

自旋锁

在锁抢夺失败时候 不挂起等待而是继续抢占 这样省掉了 线程切换的开销 缺点就是占用了处理器的时间   要是自旋了 很久还是没抢占到  那就是白白浪费了处理器资源

锁消除

jvm即时编译阶段 检测到这段代码不可能存在共享数据竞争的锁进行消除 逃逸分析 堆上的数据不会被其他线程访问 就可以把他们当作栈上的数据对待 认为他们是线程私有的就不用进行加锁处理 

锁粗化

如果一系列连续操作都是对一个对象反复进行加锁 解锁操作 甚至加锁操作出现在循环体中 即使没有线程竞争 频繁地互斥操作 也会导致 不必要的性能损耗 

将会把加锁同步的范围扩大到整个操作序列的外部这样只需要加锁一次就行了  

轻量级锁

对象头 中 Mark Word 被设计成一个非固定的动态数据结构 以便在极小的空间中储存尽量多的内容 写不下去了 

偏向锁

  • 24
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值