面试基础篇

并发编程

线程池

问题1
  • 线程池创建参数,以及这些参数对线程池工作带来的影响

    • 核心线程数
    • 最大线程数
    • 线程存活时间
      • 当线程池里面的线程空闲多长时间后,就把它回收,影响的是超过core线程,但是小于最大线程数的那部分线程,core线程是不会被回收的
    • 线程存活时间单位
    • 阻塞队列
    • 拒绝策略
      • 1.直接抛出异常
      • 2.由提交任务的线程执行
      • 3.把任务丢弃
      • 4.把阻塞队列最靠前的任务丢弃
  • 当任务数小于core数目时,就会启动一个核心线程,当继续添加任务时,超过core数目后,并不会继续启动,而是把任务存放到阻塞队列里面,如果队列也满了,并且最大线程数大于核心线程数,则会继续启动线程来执行任务,如果最大线程数也满了,但是仍然在继续添加任务,则按照拒绝策略执行,有默认的4种,同时也可以自定义异常,做持久化处理

问题2—合理配置线程池的线程数
  • 跟任务类型有关
    • cpu密集型
      • 分配尽可能少的线程,就是机器的cpu个数,最多+1,因为在操作系统调度系统中,线程所需要的数据被交换到磁盘上面,出现页缺失,出现这种情况,操作系统需要把线程需要的数据从磁盘调度到内存中去,这样就可以跑满cpu
    • io密集型
      • 需要根据任务时间来对线程数调配,一般默认是cpu乘2
    • 混合型
      • 考虑把任务拆分成cpu密集型和io密集型,如果两者消耗时间相当则能提高效率,如果相差很大,则没有拆分的必要
  • Runtime.getRuntime().availableProcessors()用来获取核心数

容器—ConcurrentHashMap

  • 线程安全的map
  • 1.7和1.8是不同的
1.7
  • 是采用的分段锁的形式,会有Segment这么一个角色,其中segment[0]和segment[14]之间是互不干扰的,segment本质上是继承了可重入锁,segment在表现出哈希冲突时是以链表的形式存储数据
1.8
  • 取消了segment这一层,直接用table数组存储,相当于是对segment做了扁平化处理,锁的粒度变得更小了,底层通过加synchronized的操作来保证线程安全,发生哈希冲突时仍然是通过链表的形式存储

  • 并且在jdk1.7中底层纯粹还是通过链表,而1.8则是链表+红黑树的形式

  • 比较重要的参数

    • sizeCtl,为负数正在进行初始化,-1表示正在进行初始化,-n表示当前有n+1个线程正在进行扩容,为0表示还没有进行初始化

    • Node是基础存储单元,TreeNode是变成红黑树节点,TreeBin是红黑树的基础结构(根)

可能的问题
  • jdk1.8链表转红黑树的阈值
    • 当链表的元素到达8,链表转红黑树,当链表数量只有6个,则红黑树转变成链表
    • 那为什么不直接用红黑树?因为红黑树插入操作过程比较复杂,插入效率更高,并且在数量比较少的时候,效率并不一定比链表好,同时在实现上面,红黑树的节点占用的空间大小基本上是node节点的两倍
  • 为什么要定义这样一个阈值?
    • 需要看hashmap的源码,因为treenode节点占用空间是普通节点的两倍,只有包括足够多的节点才转换成treenode,为什么阈值设为8,是空间与时间的权衡,hashmap是通过hashcode散布,当hashcode离散性很好时,数据会比较均匀的分配在桶里面的,几乎是不会有链表要转红黑树的情况,但是如果hashcode的离散性很差,根据统计学中泊松分布概率,当链表中的长度达到8的概率是0.00000006,链表长度超过8的概率小于千万分之一

volatile

  • 保证可见性,不能保证原子性,禁止指令重排序
可见性
  • 每次一个线程去读取volatile变量的时候,一定能读到它的最新数据,但是不能保证操作的原子性,即使是最简单的i++操作,此时就算i变量被volatile修饰,也不能保证i++是线程安全的,
原子性
  • 代码的底层为了获得更好的性能,会对指令进行重排序,所以代码的执行顺序不一定和我们写的代码顺序一致,操作系统只保证单线程程序正确执行,但是在多线程情况下,可能产生一些意想不到的情况, 使用volatile会禁止重排序,同样会带来一定的性能损失
  • 从内存语义上,==volatile会上面的i值重新刷新到线程的本地内存中,它要刷新的不仅仅是i本身,假设线程内部还有其他的所有的共享变量,比如其中一个j,于是线程会把i和j一起刷新到主内存,而我们尝试读取i变量时,又会把该线程所有的共享变量,包括i和j从主内存读取到线程的本地内存里面
实现
  • 编译器在生成字节码时,会在指令序列中插入相应的内存屏障, 来禁止指令重排序和强制内存刷新读取,对应的指令叫lock:开头的前缀指令,会对cpu总线和高速缓存加锁,这种指令可以理解为是cpu级别的锁,同时这个lock指令会把当前处理器缓存行中的数据直接写入到主内存中,同时还会使其他cpu里面缓存了相同数据的缓存行失效,当其他cpu想读取缓存时,会强制的从主内存中重写读取一份到自己的高速缓存行中去

概述aqs

  • 是jdk内锁的基础构建,像可重入锁、读写锁、CountDownLatch、信号量、线程池里面都用到了aqs
  • 成员变量state,用来表示同步状态,当我们使用aqs实现自己的同步器时,就是继承aqs,实现tryAcquire等这几个需要实现的方法,同时内部一定会有队列,当获取锁失败时,必须进入等待队列排队,同时除了等待队列以外,aqs除了本身的等待队列以外,还允许有多个condition的条件队列
  • aqs是CLH队列锁的变相实现,实现比CLH更复杂,同时每个节点获取锁的时候首先会进行自旋,自旋一定次数以后还是获取不到锁,就会进入一种挂起状态,当前驱节点释放了锁以后,这个节点被唤醒,再次以cas的方式去尝试获取锁,如果获取到了,则加锁成功
  • 显示锁里面的公平与非公平?
  • 显示锁的可重入是怎么实现的?

synchronized的实现原理

  • 是一个关键字,不管是加在同步块上还是加在方法上,编译后会自动加上MonitorEnter和MonitorExit指令,每当我们尝试获取锁的时候,需要获取monitor对象的所有权,获取锁成功并执行完代码后再释放所有权

  • synchronized的锁放在对象头中mark word中,mark word在缺省时放的是hashcode信息,对象加锁后,虚拟机会把本来的对象头信息放到虚拟机栈上去,在虚拟机栈中开辟副本,而把这个空间专门存放锁的相关信息

  • 同时从jdk1.6开始,引入了偏向锁、轻量锁机制来提高性能

什么是cas操作,缺点是什么?

  • cas操作是比较并交换的意思,是典型的乐观锁,当需要对某一个值进行修改时,cas觉得没有人跟我抢,先做了再说,但是为了防止被别人修改,需要和旧值进行比较,相同才更新,不相同说明有人跟我抢,则更新失败,而synchronized是典型的悲观锁,觉得肯定有人跟我抢,需要先把操作权限拿到
  • 缺点
    • aba问题,别人生成速度比我们快很多,从0改为1,又从1改为0,我们更新时发现一直是0,所以更新成功,但是中间实际是进行了别的更新操作的,如何解决?加版本号
    • cas有循环时间长、开销大的问题,一旦发现与旧值不相等,则会反复循环比较,当竞争锁很激烈的时候,会有线程不断重试,这样对cpu的消耗比较大,此时性能可能还不如synchronized
    • 只能保证一个共享变量的原子操作,如果只是操作多个变量,没有别的额外操作,也可以通过AtomicReference来将多个变量打包成一个变量

sql的优化

mysql所以类型和区别

  • 普通索引,一个索引只包含单列,一张表里有多个普通索引
  • 唯一索引,索引的值必须是唯一的,但是运行有空值
  • 复合索引,一个索引里面可以有多个列
  • 聚集索引和非聚集索引,innodb会把数据和索引放到一起,称为聚集索引,myisam中数据文件和索引文件是分开存放的

事务的四大特性

  • acid
  • 原子性,指事务包含的操作要么全部成功,要么全部失败
  • 一致性,事务完成后,使数据库从一个一致性状态变迁为另一个一致性状态,比如a账户的金额是5千,之后订单中消费的金额加上账户余额应该还是5千
  • 隔离性,多个用户并发访问数据库时,数据库为每一个用户开启一个事务,不能被其他事务干扰,某一个时间点只会有一个事务在执行
    • 脏读
    • 不可重复读
    • 幻读
  • 持久性,事务一旦提交,对数据库中的数据改变是永久性的,即使数据库发生故障,也不会丢失

事务的隔离级别

  • 未提交读
  • 不可重复读
  • 可重复读
  • 串行化读

mysql事务实现原理

  • 通过redo log和undo log 两个日志来实现acd四大特性,而i是通过锁和undo log来实现的
    • 持久性由redo log日志来实现,第一个在内存中重做日志缓存,第二个重做日志文件,事务提交时,会把事务写到redo log日志文件中去,直到commit后,整个事务才算操作完成
    • undo log实现事务的回滚,以及多版本并发控制。当delete 一条记录时,undo log 中会记录一条对应的 insert 记录,反
      之亦然,当 update 一条记录时,它记录一条对应相反的 update 记录。

sql优化

环境方面
  • 1、尽可能的使用高速磁盘和大内存
  • 2、服务器使用 Linux,并且进行操作系统级别的调优,比如网络参数、避免使用 Swap交换区等等
SQL 相关
  • 1、先找到慢查询,慢查询日志,顾名思义,就是查询慢的日志,是指 mysql 记录所有执行超过 long_query_time 参数设定的时间阈值的 SQL 语句的日志(默认是10秒)。该日志能为 SQL 语句的优化带来很好的帮助。默认情况下,慢查询日志是关闭的,要使用慢查询日志功能,首先要开启慢查询日志功能。
    • slow_query_log 启动停止技术慢查询日志
    • slow_query_log_file 指定慢查询日志得存储路径及文件(默认和数据文件放一起)
    • long_query_time 指定记录慢查询日志 SQL 执行时间得伐值(单位:秒,默认 10 秒)
    • log_queries_not_using_indexes 是否记录未使用索引的 SQL
    • log_output 日志存放的地方【TABLE】【FILE】【FILE,TABLE】
  • 2、分析慢查询日志。慢查询的日志记录非常多,要从里面找寻一条查询慢的日志并不是很容易的事情,一般来说都需要一些工具辅助才能快速定位到需要优化的 SQL 语句,比如Mysqldumpslow
  • 3、SQL 本身优化,比如少用子查询,in 查询改关联查询、不使用外键与级联等等
  • 4、反范式化设计(第三范式),字段允许适当冗余、选择合适的字段存储长度等等
  • 5、使用执行计划分析 SQL 语句,使用 EXPLAIN 关键字可以模拟优化器执行 SQL 查询语句,从而知道 MySQL 是如何处理你的 SQL 语句的。分析你的查询语句或是表结构的性能瓶颈,至少可以知道
    • 表的读取顺序
    • 数据读取操作的操作类型
    • 哪些索引可以使用
    • 哪些索引被实际使用
    • 表之间的引用
    • 每张表有多少行被优化器查询
    • 比如,执行计划中的 type 显示的是访问类型,是较为重要的一个指标,结果值从最好
      到最坏依次是:
      system > const > eq_ref > ref > fulltext > ref_or_null > index_merge >
      unique_subquery > index_subquery > range > index > ALL
      一般来说,得保证查询至少达到 range 级别,要求能达到 ref。
优化 10 大策略
  • 策略 1.尽量全值匹配
    当建立了索引列后,能在 wherel 条件中使用索引的尽量所用。

  • 策略 2.最佳左前缀法则
    如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始并且不跳过索引中的列。

  • 策略 3.不在索引列上做任何操作
    不在索引列上做任何操作(计算、函数、(自动 or 手动)类型转换),会导致索引失效而转向全表扫描

  • 策略 4.范围条件放最后
    中间有范围查询会导致后面的索引列全部失效

  • 策略 5.覆盖索引尽量用
    尽量使用覆盖索引(指一个查询语句的执行只用从索引中就能够取得,不必从数据表中读取,),减少 select *

  • 策略 6.不等于要慎用
    mysql 在使用不等于(!= 或者<>)的时候无法使用索引会导致全表扫描,如果一定要需要使用不等于,请用覆盖索引

  • 策略 7.Null/Not 有影响
    注意 null/not null 对索引的可能影响
    1、自定定义为 NOT NULL
    在字段为 not null 的情况下,使用 is null 或 is not null 会导致索引失效
    解决方式:覆盖索引
    2、自定义为 NULL 或者不定义
    Is not null 的情况会导致索引失效
    解决方式:覆盖索引

  • 策略 8.Like 查询要当心
    like 以通配符开头(’%abc…’)mysql 索引失效会变成全表扫描的操作
    解决方式:覆盖索引

  • 策略 9.字符类型加引号
    字符串不加单引号索引失效

    解决方式:请加引号策略

  • 10.OR 改 UNION 效率高
    解决方式:如果一定要用 OR,那么使用覆盖索引

JVM

JVM 内存区域

  • JVM 在执行 Java 程序的过程中会把它管理的内存分为若干个不同的区域,这些组成部分有些是线程私有的,有些则是线程共享的
  • 线程私有的:程序计数器,虚拟机栈,本地方法栈
  • 线程共享的:方法区,堆
    • 程序计数器
      较小的内存空间,当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响,此内存区域是唯一一个不会出现 OutOfMemoryError 情况的区域。
    • 虚拟机栈
      每个线程私有的,线程在运行时,在执行每个方法的时候都会打包成一个栈帧,存储了局部变量表,操作数栈,动态链接,方法出口等信息,然后放入栈。每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。
    • 本地方法栈
      各虚拟机自由实现,本地方法栈 native 方法调用 JNI 到了底层的 C/C++(c/c++可以触发汇编语言,然后驱动硬件
    • 方法区/ 永久代
      用于存储已经被虚拟机加载的类信息,常量(“zdy”,"123"等),静态变量(static 变量)等数据,比如类信息就包括类的完整有效名、返回值类型、修饰符(public,private…)、变量
      名、方法名、方法代码、这个类型直接父类的完整有效名(除非这个类型是 interface 或是java.lang.Object,两种情况下都没有父类)、类的直接接口的一个有序列表等等

    • 几乎所有对象都分配在这里,也是垃圾回收发生的主要区域

jvm如何确定被清除的对象

  • JVM 中是通过可达性分析算法判断对象是否可回收的。
  • 这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的
    • 静态变量
    • 局部变量
    • 常量

有哪些垃圾回收算法

  • 复制算法
    将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

  • 标记-清除算法
    标记-清除算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。但是会带来两个明显的问题:
    1)效率问题
    2)空间问题(标记清除后会产生大量不连续的碎片)

  1. 标记-整理算法
    根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
    根据对象的生命周期,将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
    比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

有哪些垃圾回收器以及各自的区别是什么

  • Serial/Serial Old
    最古老的,单线程,独占式,成熟,适合单 CPU 服务器
    -XX:+UseSerialGC 新生代和老年代都用串行收集器
    -XX:+UseParNewGC 新生代使用 ParNew,老年代使用 Serial Old
    -XX:+UseParallelGC 新生代使用 ParallerGC,老年代使用 Serial Old

  • ParNew
    和 Serial 基本没区别,唯一的区别:多线程,多 CPU 的,停顿时间比 Serial 少
    -XX:+UseParNewGC 新生代使用 ParNew,老年代使用 Serial Old
    除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

  • Parallel Scavenge (ParallerGC )/Parallel Old
    关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
    所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那有吞吐效率就是 99%。-XX:+UseParallelOldGC 则会开启这一对组
    合,同时 Parallel Scavenge还有一个自适应调节策略,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。通过打开-XX:+UseAdaptiveSizePolicy,只需要把基本的内存数据设置好(如-Xmx 设置最大堆),然后
    使用 MaxGCPauseMillis 参数(更关注最大停顿时间)或 GCTimeRatio 参数(更关注吞吐量)给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。

  • Concurrent Mark Sweep (CMS )
    收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停
    顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
    -XX:+UseConcMarkSweepGC ,一般新生代使用 ParNew,老年代的用 CMS,并发收集失败,转为 SerialOld 从名字(包含“Mark Sweep”)上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些。
    整个过程分为 4 个步骤,包括:

    • 初始标记: :仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿(STW-Stop the world)。

    • 并发标记 :从 GC Root 开始对堆中对象进行可达性分析,找到存活对象,它在整个回收过程中耗时最长,不需要停顿。

    • 重新标记 :为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿(STW)。这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

    • 并发清除:不需要停顿。

    • 优点:
      由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。关注的是低延时
      缺点:
      CPU 资源敏感:因为并发阶段多线程占据 CPU 资源,如果 CPU 资源不足,效率会明显降低。
      浮动垃圾:由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
      由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。
      在 1.6 的版本中老年代空间使用率阈值(92%)
      如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
      会产生空间碎片:标记 - 清除算法会导致产生不连续的空间碎片,CMS 只会删除无用对象,不会对内存做压缩,会造成内存碎片,这时候我们需要用到这个参数:

  • G1 垃圾回收器
    主要是用在大内存和多处理器数量的服务器上。jdk9 中将 G1 变成默认的垃圾收集器。
    G1 中重要的参数:
    -XX:+UseG1GC 使用 G1 垃圾回收器
    -XX:MaxGCPauseMillis=200 设置 GC 的最大暂停时间为 200ms

    内部布局改变
    G1 把堆划分成多个大小相等的独立区域(Region),每个 Region 大小为 2 的倍数,范围在 1MB-32MB 之间,可能为 1,2,4,8,16,32MB。所有的 Region 有一样的大小,JVM生命周期内不会改变。整个堆被划分成 2048 左右个 Region。新生代和老年代不再物理隔离。Region 可以说是 G1 回收器一次回收的最小单元。
    算法:标记—整理 (old,humongous) 和复制回收算法(survivor)

  • Stop The World 现象
    Stop the World 机制,简称 STW,主要指执行垃圾收集算法时,Java 应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起。
    此时,系统只能允许 GC 线程进行运行,其他线程则会全部暂停,等待 GC 线程执行完毕后才能再次运行。这些工作都是由虚拟机在后台自动发起和自动完成的,是在用户不可见的情况下把用户正常工作的线程全部停下来,这对于很多的应用程序,尤其是那些对于实时
    性要求很高的程序来说是难以接受的。我们 GC 调优的目标就是尽可能的减少 STW 的时间和
    次数。

JVM里面存在哪些引用

  • 强引用
    以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空 间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具
    有强引用的对象来解决内存不足问题。
  1. 软引用(SoftReference )
    如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
    软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
  2. 弱引用(WeakReference )
    如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收
    它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
    弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
  3. 虚引用(PhantomReference )
    "虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
    虚引用主要用来跟踪对象被垃圾回收的活动。
  • 在程序设计中除了强引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

类加载过程

  • 类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、
    使用(Using)和卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)

    • 加载阶段
      虚拟机需要完成以下 3 件事情:
      1)通过一个类的全限定名来获取定义此类的二进制字节流。
      2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
      3)在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据
      的访问入口。

    • 验证
      是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体上看,验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

    • 准备阶段
      是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。

      其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
      public static int value=123;
      那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器<clinit
      >()方法之中,所以把 value 赋值为 123 的动作将在后面的初始化阶段才会执行。假设上面类变量 value 的定义变为:public static final int value=123;编译时 Javac 将会为value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value赋值为 123。

    • 解析阶段
      是虚拟机将常量池内的符号引用替换为直接引用的过程。
      符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
      直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

    • 初始化阶段
      虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
      1)遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
      2)使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
      3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
      4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
      5)当使用 JDK 1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

      初始化也是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
      <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
      虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。所以类的初始化是线程安全的,项目中可以利用这点。

双亲委派模型

  • 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性.从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:
    • 一种是启动类加载器(Bootstrap ClassLoader ),这个类加载器使用 C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
  • 启动类加载器(Bootstrap ClassLoader ):这个类将器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用 null 代替即可。
  • 扩展类加载器 (Extension ClassLoader) ):这个加载器由 sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$App-ClassLoader 实现。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
  • 我们的应用程序都是由这 3 种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。
  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
  • 使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类
    加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱
  • ClassLoader 中的 loadClass 方法中的代码逻辑就是双亲委派模型:
    在自定义 ClassLoader 的子类时候,我们常见的会有两种做法,一种是重写 loadClass 方法,另一种是重写 findClass 方法。其实这两种方法本质上差不多,毕竟 loadClass 也会调用findClass,但是从逻辑上讲我们最好不要直接修改 loadClass 的内部逻辑。我建议的做法是只在 findClass 里重写自定义类的加载方法。
  • loadClass 这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委托模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写 loadClass 方法的过程中必须写双亲委托的重复代码,
    从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
  • 但是 Tomcat 中没有完全遵守双亲委派模型,是因为了实现jsp的热替换。

双亲委派模式的破坏

  • 双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的 API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?
  • 比如 JDBC 原因是原生的 JDBC 中 Driver 驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。
  • 例如,MySQL 的 mysql-connector-.jar 中的 Driver 类具体实现的。 原生的 JDBC 中的类是放在 rt.jar 包的,是由启动类加载器进行类加载的,在 JDBC 中的 Driver 类中需要动态去加载不同数据库类型的 Driver 类,而 mysql-connector-.jar 中的 Driver 类是由独立厂商实现并部署在应用程序的 ClassPath 下的,那启动类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由应用程序启动类去进行类加载。
  • 于是,这个时候就引入线程上下文件类加载器(Thread Context ClassLoader)。有了这个东西之后,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
  • Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB和 JBI 等。
  • 双亲委派模型的 “被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换(HotSwap)、模块热部署(HotDeployment)等等。

JVM 常用工具

  • jps
    列出当前机器上正在运行的虚拟机进程,JPS 从操作系统的临时目录上去找(所以有一些信息可能显示不全)。
    -q :仅仅显示进程,
    -m:输出主函数传入的参数. 下的 hello 就是在执行程序时从命令行输入的参数
    -l: 输出应用程序主类完整 package 名称或 jar 完整名称.
    -v: 列出 jvm 参数, -Xms20m -Xmx50m 是启动程序指定的 jvm 参数
  • jstat
    是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI 图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。
    假设需要每 250 毫秒查询一次进程 13616 垃圾收集状况,一共查询 10 次,那命令应当
    是:jstat -gc 13616 250 10
    常用参数:
    -class (类加载器)
    -compiler (JIT)
    -gc (GC 堆状态)
    -gccapacity (各区大小)
    -gccause (最近一次 GC 统计和原因)
    -gcnew (新区统计)
    -gcnewcapacity (新区大小)
    -gcold (老区统计)
    -gcoldcapacity (老区大小)
    -gcpermcapacity (永久区大小)
    -gcutil (GC 统计汇总)
    -printcompilation (HotSpot 编译统计)
  • jinfo
    查看和修改虚拟机的参数
    jinfo –sysprops 可以查看由 System.getProperties()取得的参数
    jinfo –flag 未被显式指定的参数的系统默认值
    jinfo –flags(注意 s)显示虚拟机的参数
    jinfo –flag +[参数] 可以增加参数,但是仅限于由 java -XX:+PrintFlagsFinal –version 查
    询出来且为 manageable 的参数
    jinfo –flag -[参数] pid 可以修改参数
    Thread.getAllStackTraces();
  • jmap
    用于生成堆转储快照(一般称为 heapdump 或 dump 文件)。jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列、Java 堆和永久代的详细信息,如空间
    使用率、当前用的是哪种收集器等。和 jinfo 命令一样,jmap 有不少功能在 Windows 平台下都是受限的,除了生成 dump 文件的-dump 选项和用于查看每个类的实例、空间占用统计的
    -histo 选项在所有操作系统都提供之外,其余选项都只能在 Linux/Solaris 下使用。
    jmap -dump:live,format=b,file=heap.bin
    Sun JDK 提供 jhat(JVM Heap Analysis Tool)命令与 jmap 搭配使用,来分析 jmap 生成的堆转储快照。
  • jhat
    jhat dump 文件名
    后屏幕显示“Server is ready.”的提示后,用户在浏览器中键入 http://localhost:7000/就可以访问详情
    使用 jhat 可以在服务器上生成堆转储文件分析(一般不推荐,毕竟占用服务器的资源,比如一个文件就有 1 个 G 的话就需要大约吃一个 1G 的资源)
  • jstack
    (Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程
    长时间停顿的常见原因。
    在代码中可以用 java.lang.Thread 类的 getAllStackTraces()方法用于获取虚拟机中所有线程的 StackTraceElement 对象。使用这个方法可以通过简单的几行代码就完成 jstack 的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈。(并发编程中的线程安全课程中有具体的案例)

项目内存或者 cpu 占用率过高如何排查

  • 一、在排查问题的过程中针对 CPU 的问题,使用以下命令组合来排查问题
    • 1、查看问题进程,得到进程 PID:
      top -c
    • 2、查看进程里的线程明细,并手动记下 CPU 异常的线程 PID:
      top -p PID -H
    • 3、使用 jdk 提供 jstack 命令打印出项目堆栈:
      jstack pid > xxx.log
      线程 PID 转成 16 进制,与堆栈中的 nid 对应,定位问题代码位置。
  • 二、针对内存问题,使用以下命令组合来排查问题:
    • 1、查看内存中的存活对象统计,找出业务相关的类名:
      jmap -histo:live PID > xxx.log
    • 2、通过简单的统计还是没法定位问题的话,就输出内存明细来分析。这个命令会将内存里的所有信息都输出,输出的文件大小和内存大小基本一致。而且会导致应用暂时挂起,所以谨慎使用。
      jmap -dump:live,format=b,file=xxx.hprof PID
    • 3、 最后对 dump 出来的文件进行分析。文件大小不是很大的话,使用 jdk 自带的 jhat命令即可:
      jhat -J-mx2G -port 7170
      4、dump 文件太大的话,可以使用 jprofiler 工具来分析。jprofiler 工具的使用,这里不做详细介绍,有兴趣可以搜索一下。
  • 三、需要分析 GC 情况,可以使用以下命令:jstat -gc PID

框架源码

谈谈依赖注入和面向切面

  • 类似的面试题:谈谈你对 Spring 框架的理解、谈谈 Spring 中的 IOC 和 AOP 概念等等
  • spring 框架是一个开源而轻量级的框架,是一个 IOC 和 AOP 容器,spring 的核心就是控制反转(IOC)和面向切面编程(AOP)
  • 控制反转(IOC ):是面向对象编程中的一种设计原则,用来降低程序代码之间的耦合度,使整个程序体系结构更加灵活,与此同时将类的创建和依赖关系写在配置文件里,由配置文件注入,达到松耦合的效果。与此同时 IOC 也称为 DI(依赖注入),依赖注入是一种开
    发模式;依赖注入提倡使用接口编程; 依赖注入使得可以开发各个组件,然后根据组件之间的依赖关系注入组装。
  • 所谓依赖,从程序的角度看,就是比如 A 要调用 B 的方法,那么 A 就依赖于 B,反正 A要用到 B,则 A 依赖于 B。所谓倒置,你必须理解如果不倒置,会怎么着,因为 A 必须要有B,才可以调用 B,如果不倒置,意思就是 A 主动获取 B 的实例:B b = new B(),这就是最简单的获取 B 实例的方法(当然还有各种设计模式可以帮助你去获得 B 的实例,比如工厂、Locator 等等),然后你就可以调用 b 对象了。所以,不倒置,意味着 A 要主动获取 B,才能使用 B;到了这里,就应该明白了倒置的意思了。倒置就是 A 要调用 B 的话,A 并不需要主动获取 B,而是由其它人自动将 B 送上门来。
  • 形象的举例就是:
    通常情况下,假如你有一天在家里口渴了,要喝水,那么你可以到你小区的小卖部去,告诉他们,你需要一瓶水,然后小卖部给你一瓶水!这本来没有太大问题,关键是如果小卖部很远,那么你必须知道:从你家如何到小卖部;小卖部里是否有你需要的水;你还要考虑
    是否开着车去;等等等等,也许有太多的问题要考虑了。也就是说,为了一瓶水,你还可能需要依赖于车等等这些交通工具或别的工具,问题是不是变得复杂了?那么如何解决这个问题呢?
    解决这个问题的方法很简单:小卖部提供送货上门服务,凡是小卖部的会员,你只要告知小卖部你需要什么,小卖部将主动把货物给你送上门来!这样一来,你只需要做两件事情,你就可以活得更加轻松自在:
    第一:向小卖部注册为会员。
    第二:告诉小卖部你需要什么。
    这和 Spring 的做法很类似!Spring 就是小卖部,你就是 A 对象,水就是 B 对象
    第一:在 Spring 中声明一个类:A
    第二:告诉 Spring,A 需要 B
  • 面向切面编程(AOP)将安全,事务等于程序逻辑相对独立的功能抽取出来,利用 Spring的配置文件将这些功能插进去,实现了按照切面编程,提高了复用性;最主要的作用:可以在不修改源代码的情况下,给目标方法动态添加功能
  • 面向切面编程的目标就是分离关注点。什么是关注点呢?就是你要做的事,就是关注点。假如你是个公子哥,没啥人生目标,天天就是衣来伸手,饭来张口,整天只知道玩一件事!那么,每天你一睁眼,就光想着吃完饭就去玩(你必须要做的事),但是在玩之前,你还需
    要穿衣服、穿鞋子、叠好被子、做饭等等等等事情,这些事情就是你的关注点,但是你只想吃饭然后玩,那么怎么办呢?这些事情通通交给别人去干。在你走到饭桌之前,有一个专门的仆人 A 帮你穿衣服,仆人 B 帮你穿鞋子,仆人 C 帮你叠好被子,仆人 C 帮你做饭,然后你就开始吃饭、去玩(这就是你一天的正事),你干完你的正事之后,回来,然后一系列仆人又开始帮你干这个干那个,然后一天就结束了!
  • AOP 的好处就是你只需要干你的正事,其它事情别人帮你干。也许有一天,你想裸奔,不想穿衣服,那么你把仆人 A 解雇就是了!也许有一天,出门之前你还想带点钱,那么你再雇一个仆人 D 专门帮你干取钱的活!这就是 AOP。每个人各司其职,灵活组合,达到一
    种可配置的、可插拔的程序结构。
  • 从 Spring 的角度看,AOP 最大的用途就在于提供了事务管理的能力。事务管理就是一个关注点,你的正事就是去访问数据库,而你不想管事务(太烦),所以,Spring 在你访问数据库之前,自动帮你开启事务,当你访问数据库结束之后,自动帮你提交/回滚事务!
  • Spring 优点:a:低侵入式设计,独立于各种应用服务器,b:依赖注入特点性将组件关系透明化,降低耦合度 c:与第三方框架具有良好的整合效果

解释 Spring 框架中 bean 实例化的流程

  • Spring 容器 从 XML 文件中读取 bean 的定义,并实例化 bean。
  • Spring 根据 bean 的定义填充所有的属性。
  • 如果 bean 实现了 BeanNameAware 接口,Spring 传递 bean 的 ID 到 setBeanName 方法。
  • 如果 Bean 实现了 BeanFactoryAware 接口, Spring 传递 beanfactory 给 setBeanFactory 方法。
  • 如果有任何与 bean 相关联的 BeanPostProcessors,Spring 会在
    postProcesserBeforeInitialization()方法内调用它们。
  • 如果 bean 实现 IntializingBean 了,调用它的 afterPropertySet 方法,
  • 如果声明了初始化方法,调用此初始化方法。
  • 如果有 BeanPostProcessors 和 bean 关联,这些 bean 的 postProcessAfterInitialization() 方法将被调用。

Spring Bean 的生命周期

  • 无非就是在 bean 实例化的流程的基础之上,增加了 Spring 容器销毁 Bean 的过程,包括了执行有@PreDestroy 注解的方法,然后是

    调用DisposibleBean的destory方法,最后调用的destroy-method属性指定的初始化方法

Spring 在 Bean 创建过程中是如何解决循环依赖的?

  • 循环依赖只会存在在单例实例中,多例循环依赖直接报错。
  • A 类实例化后,把实例放 map 容器中,A 类中有一个 B 类属性,A 类实例化要进行 IOC 依赖注入,这时候 B 类需要实例化,B 类实例化跟 A 类一样,实例化后方 map 容器中。B 类中有一个 A 类属性,接着 B 类的 IOC 过程,又去实例化 A 类,这时候实例化 A 类过程中从 map容器发现 A 类已经在容器中了,就直接返回了 A 的实例,依赖注入到 B 类中 A 属性中,B类 IOC 完成后,B 实例化就完全完成了,就返回给 A 类的 IOC 过程。这就是循环依赖的解决。

AOP 实现流程

  • 1、aop:config 自定义标签解析
  • 2、自定义标签解析时封装对应的aop 入口类,类的类型就是 BeanPostProcessor 接口类型
  • 3、Bean 实例化过程中会执行到 aop 入口类中
  • 4、在 aop 入口类中,判断当前正在实例化的类是否在 pointcut 中,pointcut 可以理解为一个模糊匹配,是一个 joinpoint 的集合
  • 5、如果当前正在实例化的类在 pointcut 中,则返回该 bean 的代理类,同时把所有配置的advice 封装成 MethodInterceptor 对象加入到容器中,封装成一个过滤器链
  • 6、代理对象调用,jdk 动态代理会调到 invocationHandler 中,cglib 型代理调到MethodInterceptor 的 callback 类中,然后在 invoke 方法中执行过滤器链。

Spring 框架中如何基于 AOP 实现的事务管理?

  • 事务管理,是一个切面。在 aop 环节中,其他环节都一样,事务管理就是由 Spring 提供的advice,既是 TransactionInterceptor,它一样的会在过滤器链中被执行到,这个TransactionInterceptor 过滤器类是通过解析tx:advice自定义标签得到的。

描述一下 SpringMvc 的整个访问或者调用流程。

  • 1:发起请求到前端控制器(DispatcherServlet)
  • 2:前端控制器请求 HandlerMapping 查找 Handler( 可以根据 xml 配置、注解进行查找),处理器映射器 HandlerMapping 向前端控制器返回 Handler
  • 3:前端控制器调用处理器适配器去执行 Handler
  • 4:处理器适配器去执行 Handler
  • 5:Handler 执行完成给适配器返回 ModelAndView,处理器适配器向前端控制器返回ModelAndView(ModelAndView 是 springmvc 框架的一个底层对象,包括 Model 和 view)
  • 6:前端控制器请求视图解析器去进行视图解析(根据逻辑视图名解析成真正的视图(jsp)),视图解析器向前端控制器返回 View
  • 7:前端控制器进行视图渲染( 视图渲染将模型数据(在ModelAndView对象中)填充到request域)
  • 8:前端控制器向用户响应结果
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值