开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

单例模式为什么需要volatile关键字?

发表于 2021-02-19

本文出自——可乐可乐可,作者主页链接:可乐可乐可的个人主页

在单例模式中,为了保证效率的同时,保证线程安全,我们会了解这一段代码
双重校验锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public class SingletonLazy {
private volatile static SingletonLazy data;
private SingletonLazy(){
System.out.println("初始化");
}
public SingletonLazy getData() {
if (data == null) {
synchronized (SingletonBase.class){
data=new SingletonLazy();
}
}
return data;
}
}

要点:临界区必须为类对象,属性必须使用volatile关键字
敲黑板,这里的属性必须为volatile关键字,否则会产生不安全因素

再次科普一下什么是volatile
volatile是java中的关键字,主要有两个功能,禁止指令重排,保证可见性
禁止指令重排:机器在运行过程中,存在指令重排序(目的是为了提高机器资源利用率),但是指令的重排对编程来说是存在隐患的,Happens Before原则

在对象初始化的过程中,data=new SingletonLazy();这一行将在字节码等级被编译为四句命令

查看生成字节码的办法
javac xxxx.java 进行编译
javap 查看字节码,推荐使用javap -v xxxxx.class

结果如下new字节码

  1. new步骤开辟了空间并将该空间分配给该对象
  2. dup初步初始化了对象(赋值为0)
  3. invokespecial为用户定义的构造方法
  4. putstatic 为赋值给引用

依据happens before原则,第1、2步的顺序计算机将不会改变(有了空间才能操作)
但是第3、4步却是可以重排序的
说人话就是在构造方法执行前,引用就拿到了对象值

为了防止对象没有初始化
我们引入volatile,禁止3、4步骤重排序,保证线程安全

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

什么B+树索引,为什么MySQL使用B+树索引,而Mongo

发表于 2021-02-19

什么是B+树索引,很多人在面试的时候总是被问到,也有很多人是说不清楚的。其根本原因是没有去研究问题本身什么是B+树索引,而是简单只是去背书上或别人博客里列出的特性列表。

要回答什么是B+树,首先需要什么是B树索引(也有被翻译成B-树了,其实2个是一回事,之所以会被翻译成B-树,其实是英文里面是叫B-tree index,-其实是英文的连接符,如果要翻译成B-树,那B+就应该叫B+-树了,所以B-的翻译是不对的,但是大家看到了需要知道其实就是B树索引)。来看一下B

树索引的定义:

  • 定义任意非叶子结点最多只有M个儿子,且M>2;
  • 根结点的儿子数为[2, M];
  • 除根结点以外的非叶子结点的儿子数为[M/2, M];
  • 每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个关键字)
  • 非叶子结点的关键字个数=指向儿子的指针个数-1;
  • 非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
  • 非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树;
  • 所有叶子结点位于同一层;

    看起来是不是比较晕,记住3点就可以了:
  • B树的节点可以有多个子节点;
  • B树非叶子节点也可以存放数据;
  • 所有的叶子节点位于同一层。

B树查询到一个数据的时间复杂度是O(1) ~ logN。B树核心要解决的问题就是为了更适合磁盘存储,操作系统读取磁盘数据最小是以扇区为单位读取的。而同一节点可以放多个数据,可以减少IO的读取次数。

再来看一下B+树(只列出与B树区别的地方)

  • 只在叶子节点存储数据(或者指向存放数据的指针,例如MySQL的非主键索引);
  • 为叶子节点增加一个指向下一个数据的指针;

    B+树的时间复杂度固定是logN。看起来B+树查询到一个数据的时间复杂度要差于B树,那为什么MySQL要使用B+树而不是B树呢?其实最根本的原因是MySQL是一个关系型数据库,范围查询是比较频繁的,例如
1
sql复制代码SELECT * FROM person WHERE age > 18

所以B+树就比较合适,核心是它拥有指向下一个数据的指针,所以要做范围查询等就比较合适。

而像MongoDB,他是一个NoSQL数据库,NoSQL数据库一般是使用key来查询的,比较少会使用范围查询(虽然MongoDB也支持),所以范围查询查询的需求不是那么的强烈。反而如果如果有一部分热key如果命中O(1),那么这些热key的查询效率就是O(1)。

参考文献:

Index: Mongo uses B-tree, MySQL uses B + tree

为什么MySQL数据库索引选择使用B+树?

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

CSS块元素基础、内联元素(行内元素)、内联块元素(行内块元

发表于 2021-02-19

一、块元素

1. 常用块元素标签有:div,p,ul,li,h1~h6,dl,dt,dd等

2. 支持全部样式

3.可以设置宽高,如果没有设置宽度属性,则宽度默认为父级宽度的百分之百

4. 盒子占据一行,即使设置了宽度

如图 .box并未设置宽度:

CSS会自动将宽度设定为父级的百分之百,且独占一行,加上宽度即可正常显示设定的宽度

二、内联元素(行内元素)

注意:常见的内联元素:a,span,em,b,strong,i

1. 不支持宽高,margin上下、padding上下,如图 .box2已设置margin上下,但并未生效。padding上下因为其不生效所以设置为0,如果强行设置反而会导致渲染bug,padding左右已生效

)

2. 宽高由元素内容决定,如果是图片就为图片大小,如果字体30就按照30

3. 盒子并在一行中

4. 代码换行时盒子会自动产生间距

解决办法:

将父元素中的font-size设置为0,再重新设置子元素的font-size:

父级: 子级:

效果:

原效果

5.子元素是内联元素,父元素可用text-align设置对其方式

父元素.box3: 子元素:.box3 a

添加了text-align:center; 后块元素内的内联元素居中对其

三、内联块元素(块元素+内联元素)

1. 支持全部样式

2.可以自己设置宽高,如果元素没有设置宽高,则由内容决定

3.盒子并在一行

4. 代码换行,盒子产生间距

5. 父元素可用text-align设置子元素对其方式

可用display将内联元素转换为内联块元素,使其可以设置宽高,margin上下以及padding上下

元素中添加:

,即可将内联元素转化为内联块元素

更多display属性:

可实现在块元素、内联元素、内联快元素中切换

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

HTTP 中的 MIME 类型 MIME 类型 语法

发表于 2021-02-19

MIME 类型

MIME (Multipurepose Internet Mail Extensions 多用途互联网邮件扩展 | 互联网媒体类型) 是一种用来表示 文档、文件、字节流 性质和格式的标准;定义和标准化于 IETF RFC 6838m

MIME 规定了用于表示各种各样的数据类型的符号化发昂发;另外,在 HTTP 协议中也使用了 MIME 框架,标准被扩展为互联网媒体类型

在浏览器中常常使用 MIME 类型来确定处理 URL 的方式,在 HTTP 响应头中使用正确的 MIME 类型对正确处理文件十分重要;但是,MIME 类型不是传达文档类型信息的唯一方式

语法

0x01 通用结构

1
txt复制代码type/subtype

不允许空格;大小写不敏感

0x02 type 独立类型

文本文件没有特定的 subtype,使用 text/plain

二进制文件没有特定或已知的 subtype,使用 application/octet-stream

0x02 Multipart 类型

1
2
txt复制代码multipart/form-data
multipart/byteranges

Multipart 类型表示细分领域的文件类型的种类,对应不同的 MIME 类型;是复合文件的一种表现方式

multipart/form-data 用于联系 HTML Forms 和 POST 方法;multipart/byteranges 使用状态码 206 (Partial Content) 发送整个文件的子集


Reference

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Gradle 系列(9)代码混淆到底做了什么? 前言 目录

发表于 2021-02-19

请点赞关注,你的支持对我意义重大。

🔥 Hi,我是小彭。本文已收录到 GitHub · AndroidFamily 中。这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] 带你建立核心竞争力。

前言

代码混淆对于每个入门的 Android 工程师来说都不会太陌生,因为在编译正式版本时,这是一个必不可少的过程。而且使用代码混淆也相当简单,简单到只需要配置一句minifyEnabled true。但是你是否理解混淆的原理,如果问你代码混淆到底做了什么,你会怎么说?


这篇文章是 Gradle 系列文章第 5 篇,相关 Android 工程化专栏完整文章列表:

一、Gradle 基础:

  • 1、Gradle 基础 :Wrapper、Groovy、生命周期、Project、Task、增量
  • 2、Gradle 插件:Plugin、Extension 扩展、NamedDomainObjectContainer、调试
  • 3、Gradle 依赖管理
  • 4、Maven 发布:SHAPSHOT 快照、uploadArchives、Nexus、AAR
  • 5、Gradle 插件案例:EasyPrivacy、so 文件适配 64 位架构、ABI

二、AGP 插件:

  • 1、AGP 构建过程
  • 2、AGP 常用配置项:Manifest、BuildConfig、buildTypes、壳工程、环境切换
  • 3、APG Transform:AOP、TransformTask、增量、字节码、Dex
  • 4、AGP 代码混淆:ProGuard、R8、Optimize、Keep、组件化
  • 5、APK 签名:认证、完整性、v1、v2、v3、Zip、Wallet
  • 6、AGP 案例:多渠道打包

三、组件化开发:

  • 1、方案积累:有赞、蘑菇街、得到、携程、支付宝、手淘、爱奇艺、微信、美团
  • 2、组件化架构基础
  • 3、ARouter 源码分析
  • 4、组件化案例:通用方案
  • 5、组件化案例:组件化事件总线框架
  • 6、组件化案例:组件化 Key-Value 框架

四、AOP 面向切面编程:

  • 1、AOP 基础
  • 2、Java 注解
  • 3、Java 注解处理器:APT、javac
  • 4、Java 动态代理:代理模式、Proxy、字节码
  • 5、Java ServiceLoader:服务发现、SPI、META-INF
  • 6、AspectJ 框架:Transform
  • 7、Javassist 框架
  • 8、ASM 框架
  • 9、AspectJ 案例:限制按钮点击抖动

五、相关计算机基础

  • 1、Base64 编码
  • 2、安全传输:加密、摘要、签名、CA 证书、防窃听、完整性、认证

目录


  1. 混淆编译器

如果以混淆编译器来划分的话,Android 代码混淆可以分为以下两个时期:

  • ProGuard:一个通用的 Java 字节码优化工具,由比利时团队 GuardSquare 开发
  • R8:ProGuard 的继承者,专为 Android 设计,编译性能和编译产物更优秀

下图梳理了它们随着 Android Gradle Plugin 版本迭代相应做出的变更:

Android Gradle Plugin 版本迭代

其中,混淆编译器的变更:

  • 远古: ProGuard
  • 3.2.0:ProGuard(默认),R8(引入)
  • 3.4.0:R8(默认)

其中:DEX编译器的变更:

  • 远古: DX
  • 3.0.0:DX(默认),D8(引入)
  • 3.1.0:D8(默认)

如果需要修正 Android Gradle Plugin 的默认行为,可以在gradle.properties中添加配置:

  • 启用与禁用 R8
1
2
ini复制代码# 显式启用 R8
android.enableR8 = true
1
2
3
4
ini复制代码# 1. 只对 Android Library module 停用 R8 编译器
android.enableR8.libraries = false
# 2. 对所有 module 停用 R8 编译器
android.enableR8 = false
  • 启用与禁用 D8
1
2
ini复制代码# 显式启用 D8
android.enableD8 = true
1
2
ini复制代码# 显式禁用 D8
android.enableD8 = false

另外,如果在应用模块的 build.gradle 文件中设置useProguard = false,也会使用 R8 编译器代替 ProGuard。


  1. 四大功能

ProGuard 与 R8 都提供了压缩(shrinker)、优化(optimizer)、混淆(obfuscator)、预校验(preverifier)四大功能:

  • 压缩(也称为摇树优化,tree shaking):从 应用及依赖项 中移除 未使用 的类、方法和字段,有助于规避 64 方法数的瓶颈
  • 优化:通过代码 分析 移除更多未使用的代码,甚至重写代码
  • 混淆:使用无意义的简短名称 重命名 类/方法/字段,增加逆向难度
  • 预校验:对于面向 Java 6 或者 Java 7 JVM 的 class 文件,编译时可以把 预校验信息 添加到类文件中(StackMap 和 StackMapTable属性),从而加快类加载效率。预校验对于 Java 7 JVM 来说是必须的,但是对于 Android 平台 无效

使用 ProGuard 时,部分编译流程如下图所示:

  • ProGuard 对 .class 文件执行代码压缩、优化与混淆
  • D8 编译器执行脱糖,并将 .class 文件转换为 .dex文件

使用 R8 时,部分编译流程如下图所示:

  • R8 将脱糖(Desugar)、压缩、优化、混淆和 dex(D8 编译器)整合到一个步骤
  • R8 对 .class 文件执行代码压缩、优化与混淆
  • D8 编译器执行脱糖,并将 .class 文件转换为 .dex文件

对比以下 ProGuard 与 R8 :

  • 共同点:
    1、开源

2、R8 支持所有现有 ProGuard 规则文件

3、都提供了四大功能:压缩、优化、混淆、预校验

  • 不同点:
    1、ProGuard 可用于 Java 项目,而 R8 专为 Android 项目设计

2、R8 将脱糖(Desugar)、压缩、优化、混淆和 dex(D8 编译器)整合到一个步骤中,显着提高了编译性能

关于 D8 编译器

将 .class 字节码转化为 .dex 字节码的过程被称为 DEX 编译,最初是由DX 编译器完成。与 DX 编译器相比,新的 D8 编译器的编译速度 更快,输出的 .dex 文件 更小 ,却能保持相同乃至 更出色 的应用运行时性能


  1. 使用示例

无论使用 R8 还是 ProGuard,默认不会启用压缩、优化和混淆功能。 这个设计主要是出于两方面考虑:一方面是因为这些编译时任务会增加编译时间,另一方面是因为如果没有充分定义混淆保留规则,还可能会引入运行时错误。因此,最好 只在应用的测试版本和发布版本中启用这些编译时任务,参考使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码// build.gradle
...
android {

buildTypes {

// 测试版本
preview {
// 启用代码压缩、优化和混淆(由R8或者ProGuard执行)
minifyEnabled true
// 启用资源压缩(由Android Gradle plugin执行)
shrinkResources true
// 指定混淆保留规则文件
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}

// 发布版本
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}

// 开发版本
debug{
minifyEnabled false
}
}
...
}
  • minifyEnabled:(默认情况下)启用代码压缩、优化、混淆与预校验
  • shrinkResources:启用资源压缩
  • proguardFiles、proguardFile:指定 ProGuard 规则文件,前者可以指定多个参数。下面两段配置的作用是一样的。
1
2
3
4
5
java复制代码// 方式一:
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
// 方式二:
proguardFile getDefaultProguardFile('proguard-android-optimize.txt')
proguardFile 'proguard-rules.pro'

前面提到了:无论使用R8还是ProGuard,压缩、优化和混淆功能都是 默认关闭的。通过以下配置可以灵活控制:

  • 整体关闭
1
2
3
java复制代码minifyEnabled false
// 这行就没有效果了
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
  • 关闭压缩
1
diff复制代码-dontshrink
  • 关闭优化(R8 无效)
1
diff复制代码-dontoptimize

注意:R8 不能关闭优化,也不允许修改优化的行为,事实上,R8 会忽略修改默认优化行为的规则。例如设置 -optimizations 和 -optimizationpasses后会得到编译时警告:

1
2
css复制代码AGPBI: {"kind":"warning","text":"Ignoring option: -optimizations","sources":[{"file":"省略..."}],"tool":"D8"}
AGPBI: {"kind":"warning","text":"Ignoring option: -optimizationpasses","sources":"省略..."}],"tool":"D8"}
  • 关闭混淆(建议在开发版本关闭混淆)
1
diff复制代码-dontobfuscate
  • 关闭预校验(对 Android 平台无效,建议关闭)
1
diff复制代码-dontpreverify

  1. ProGuard 规则文件

R8 延续了 ProGuard 使用规则文件修改默认行为的做法。在很多时候,规则文件也被称为混淆保留规则文件,这是因为该文件内定义的绝大多数规则都是和代码混淆相关的。事实上,文件内还可以定义代码压缩、优化和预校验规则,因此称为 ProGuard 规则文件比较严谨。

在上一节里,我们提到了使用proguardFiles和proguardFile指定 ProGuard 规则文件。对于任何一个项目,它的 ProGuard 规则文件有以下三种来源:

  • 1、Android Gradle 插件
    在编译时,Android Gradle 插件会生成 proguard-android-optimize.txt、 proguard-android.txt,位置在<module-dir>/build/intermediates/proguard-files/。这两个文件中除了注释之外,唯一的区别是前者启用了如下代码压缩,而后者关闭了代码压缩,如下所示:
1
2
3
4
5
yaml复制代码# proguard-android-optimize.txt
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*
-optimizationpasses 5
-allowaccessmodification
相同部分省略...
1
2
3
arduino复制代码# proguard-android.txt
-dontoptimize
相同部分省略...

其中相同的那部分混淆规则中,下面这一部分是比较特殊的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
kotlin复制代码-keep class android.support.annotation.Keep
-keep class androidx.annotation.Keep
// 保留@Keep注解的类,保留...TODO
-keep @android.support.annotation.Keep class * {*;}
-keep @androidx.annotation.Keep class * {*;}
// 保留@Keep修饰的方法
-keepclasseswithmembers class * {
@android.support.annotation.Keep <methods>;
}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <methods>;
}
// 保留@Keep修饰的字段
-keepclasseswithmembers class * {
@android.support.annotation.Keep <fields>;
}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <fields>;
}
// 保留@Keep修饰的构造方法
-keepclasseswithmembers class * {
@android.support.annotation.Keep <init>(...);
}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <init>(...);
}

它指定了与@Keep注解相关的所有保留规则,这里就解释了为什么使用@Keep修饰的成员不会被混淆了吧?

  • 2、Android Asset Package Tool 2 (AAPT2)
    在编译时,AAPT2 会根据对 Manifest 中的类、布局及其他应用资源的引用来生成aapt_rules.txt,位置在<module-dir>/build/intermediates/proguard-rules/debug/aapt_rules.txt。
    例如,AAPT2 会为 Manifest 中注册的每个组件添加保留规则:
1
2
3
kotlin复制代码Referenced at [项目路径]/app/build/intermediates/merged_manifests/release/AndroidManifest.xml:19
-keep class com.have.a.good.time.MainActivity { <init>(); }
省略...

在这里,AAPT2 生成了MainActivity的保留规则,同时它还指出了引用出处:AndroidManifest.xml:19。这是因为 启动 Activity 的过程中,需要使用反射的方式实例化具体的每一个 Activity ,有兴趣可以看下 ActivityThread#performLaunchActivity() -> Instrumentation#newActivity()

  • 3、Module
    创建新 Module 时,IDE 会在该模块的根目录中创建一个 proguard-rules.pro 文件。当然,除了这个自动生成的文件,还可以按需创建额外的规则文件。例如,下面的配置对 release 添加了额外的规则文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
arduino复制代码...
android {
...
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
productFlavors {
dev{
...
}
release{
proguardFile 'release-rules.pro'
}
}
}
...

小结一下:

规则文件来源 描述
Android Gradle 插件 在编译时,由 Android Gradle 插件生成
AAPT2 在编译时,AAPT2 根据对应用清单中的类、布局及其他应用资源的引用来生成保留规则
Module 创建新 Module 时,由 IDE 创建,或者另外按需创建

如果将 minifyEnabled 属性设为 true,ProGuard 或 R8 会将来自上面列出的所有可用来源的规则组合在一起。为了看到完整的规则文件,可以在proguard-rules.pro 中添加以下配置,输出编译项目时应用的所有规则的完整报告:

1
arduino复制代码-printconfiguration build/intermediates/proguard-files/full-config.txt

  1. 组件化混淆

在组件化的项目中,需要注意应用 Module 和 Library Module 的行为差异和组件化的资源汇合规则,总结为以下几个重点:

  • 编译时会依次对各层 Library Module进行编译,最底层的 Base Module 会最先被编译为 aar 文件,然后上一层编译时会将依赖 Module 输出的 aar 文件/ jar 文件解压到模块的 build 中相应的文件夹中
  • App Module 这一层汇总了全部的 aar 文件后,才真正开始编译操作
  • 后编译的 Module 会覆盖之前编译的 Module 中的同名资源

组件化资源汇总

Lib Module 汇总到 App Module

使用较高版本的 Android Gradle Plugin,不会将汇总的资源放置在 exploded-aar文件夹。即便如此,Lib Module 的资源汇总到 App Module 的规则是一样的。

我们通过一个简单示例测试不同配置下的混淆结果:

配置一 配置二 配置三 配置四
App Module 开启混淆 X X √ √
Base Module 开启混淆 X √ X √

示例程序:App Module 依赖了 Base Module

将构建的 apk 包拖到 Android Studio 面板上即可分析 Base 类混淆结果,例如配置一的结果:

使用配置一时,Base 类没有被混淆

全部测试结果如下:

配置一 配置二 配置三 配置四
App Module 开启混淆 X X √ √
Base Module 开启混淆 X √ X √
(结果)Base 类是否被混淆 X X √ √

可以看到,混淆开启由 App Module 决定, 与Lib Module 无关。

现在我们分别在 Lib Module 和 App Module 的 proguard-rules.pro中添加 Base 类的混淆保留规则,并在 build.gradle中添加配置文件,测试 Base 类是否能保留:

1
kotlin复制代码-keep class com.rui.base.Base

测试结果如下:

配置位置 Lib Module App Module
(结果)Base 类是否保留 X √

可以看到:(默认情况)混淆规则以 App Module 中的混淆规则文件为准。

这里就引入两种主流的组件化混淆方案:

  • 在 App Module 中设置混淆规则

这种方案将混淆规则都放置到 App Module 的proguard-rules.pro中,最简单也最直观,缺点是移除 Lib Module 时,需要从 App Module 中移除相应的混淆规则。尽管多余的混淆规则并不会造成编译错误或者运行错误,但还是会影响编译效率。

很多的第三方 SDK,就是采用了这种组件化混淆方案。在 App Module 中添加依赖的同时,也需要在proguard-rules.pro中添加专属的混淆规则,这样才能保证release版本正常运行。

  • 在 App Module 中设置公共混淆规则,在 Lib Module 中设置专属混淆规则

这种方案将专属的混淆规则设置到 Lib Module 的proguard-rules.pro,但是根据前面的测试,在 Lib Module 中设置的混淆规则是不生效的。为了让规则生效,还需要在 Lib Module 的build.gradle中添加以下配置:

1
2
3
4
5
6
arduino复制代码...
android{
defaultConfig{
consumerProguardFiles 'consumer-rules.pro'
}
}

其中consumer-rules.pro文件:

1
kotlin复制代码-keep class com.rui.base.Base

测试结果表明,Base 类已经被保留了。这种使用consumerProguardFiles的方式有以下几个特点:

  • consumerProguardFiles只对 Lib Module 生效,对 App Module 无效
  • consumerProguardFiles会将混淆规则输出为proguard.txt文件,并打包进 aar 文件
  • App Module 会使用 aar 文件中的proguard.txt汇总为最终的混淆规则,这一点可以通过前面提到的-printconfiguration证明

  1. 总结

  • ProGuard 是 Java 字节码优化工具,而 R8 是专为 Android 设计的,编译性能和编译产物更优秀;
  • ProGuard 与 R8 都提供了四大功能:压缩、优化、混淆和预校验。ProGuard 主要是对 .class 文件执行代码压缩、优化与混淆,再由 D8 编译器执行脱糖并转换为 .dex 文件。R8 将压缩、优化、混淆、脱糖和 dex 整合为一个步骤;
  • ProGuard 规则文件有三种来源:Android Gradle 插件、AAPT2、Module;
  • 默认情况下,混淆规则以 App Module 中的混淆规则文件为准,使用 consumer-rules.pro 文件可以设置 Lib Module 专属混淆规则。

参考资料

  • 压缩您的应用
  • ProGuard | Office website
  • ProGuard | Manual
  • R8 | Google Git
  • Android Gradle plugin release notes
  • 《深入理解Java虚拟机 — JVM高级特性与最佳实践》 周志明 著
  • 《Android 组件化架构》 仓王 著
  • 《Android开发高手课》 张绍文 著,极客时间 出品

我是小彭,带你构建 Android 知识体系。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JVM 虚拟机手册

发表于 2021-02-18

首先分享之前的所有文章 , 欢迎点赞收藏转发三连下次一定 >>>> 😜😜😜

文章合集 : 🎁 juejin.cn/post/694164…

Github : 👉 github.com/black-ant

CASE 备份 : 👉 gitee.com/antblack/ca…

  • 当前源码 Git

前言

前段时间翻看自己多年以来攒下的满满家当 , 突然有一种满满的满足感 .
但是想想多年来找资料的艰辛 , 决定将这些文档整理出来, 分享给大家 .
笔记华而不实 , 其中可能也有不正确的地方 , 欢迎指正.
在此也感谢道友们的奉献 , 文档暂分为几个:

另外还有其他的笔记会陆陆续续的分享处理 , 谢谢大家的支持 .

一 . 基础知识

1.1 内存溢出

1.2 系统的线程划分

1
2
3
4
5
6
7
8
java复制代码// 串行收集器
: 用单线程处理所有垃圾回收工作 , 效率高
: 数据量比较小(100M左右);单处理器下并且对响应时间无要求的应用
// 并行收集器
: “对吞吐量有高要求”,多CPU、对应用响应时间无要求的中、大型应用

// 并发处理器:
: 对响应时间有高要求”,多CPU、对应用响应时间有较高要求的中、大型应用

1.3 Java 的四种引用类型

  • 强引用 (StrongReference) : 通常为 new 直接创建 , 只要还有对象指向 , 就不会发生回收
  • 软引用(SoftReference): 内存溢出之前会 clear 弱引用 , 如果还是存在 , 则抛出OutofMemory (extends WeakReference)
  • 弱引用(WeakReference): 只会生存到下一次生命周期之前 , SoftReference (new SoftReference(res))
  • 虚引用(PhantomReference): 主要作用是在垃圾收集时拿到一个通知
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码// 软引用创建案例一 : 
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);

// 软引用创建案例二 :
ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
String str = new String("abc");
SoftReference<String> softReference = new SoftReference<>(str, referenceQueue);

softReference.get()
referenceQueue.poll()

// 弱引用创建案例:
WeakReference<String> weakReference = new WeakReference<>(str);

// 虚引用创建案例 :
ReferenceQueue queue = new ReferenceQueue(); // 创建虚引用,要求必须与一个引用队列关联
PhantomReference pr = new PhantomReference(str, queue);

1.4 内存分配的方式

内存分配有2种方式 :

  • 指针碰撞 : 内存规整 , 移动指针切换分配内存
  • 空闲列表 : 维护列表记录哪些内存可用 , 从列表中获取

1.5 TLAB

什么叫 TLAB ?

TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。 如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

在代码流程中 , 对象通过引用指向实际的内存空间 , 而指向的即为对应的指针

在堆内存中 , 一片内存被一个指针一分为2 , 左边为已经分配内存的空间,右侧为空 , 每一次有新的对象创建,指针就会向右移动一个对象size的距离 , 这就被称为指针碰撞。但是当多线程高并发情况下 , 会出现指针来不及修改的情况

TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项 - XX:TLABWasteTargetPercent 设置TLAB空间所占用Eden空间的百分比大小。

TLAB的本质其实是三个指针管理的区域:start,top 和 end,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。

TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。

TLAB 的缺陷

  1. TLAB空间大小是固定的,但是这时候一个大对象,我TLAB剩余的空间已经容不下它了。(比如100kb的TLAB,来了个110KB的对象)
  2. TLAB空间还剩一点点没有用到,有点舍不得。
  3. Eden空间够的时候,你再次申请TLAB没问题,我不够了,Heap的Eden区要开始GC,
  4. TLAB允许浪费空间,导致Eden区空间不连续,积少成多。以后还要人帮忙打理。

二 . 虚拟机

2 . 1 Java 虚拟机

Java 虚拟机,是一个可以执行 Java 字节码的虚拟机进程 , 它允许Java 查询在多个任意平台使用 , 但是跨平台的是 Java 程序(包括字节码文件) , 而不是 JVM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码// 内存区
- 将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块
- 如实际机器上的各种功能的寄存器或者 PC 指针的记录器等。

// 执行引擎
执行引擎的任务是负责执行 class 文件中包含的字节码指令,相当于实际机器上的 CPU 。

// 本地方法调用
调用 C 或 C++ 实现的本地方法的代码返回结果。

// 类加载器
在 JVM 启动时或者类运行时将需要的 class 加载到JVM中

> 运行时数据区=====================================================
// 程序计数器
Java 线程私有,类似于操作系统里的 PC 计数器,它可以看做是当前线程所执行的字节码的行号指示器。
- 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
- 如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)

// 虚拟机栈(栈内存)
Java线程私有,虚拟机栈描述的是 Java 方法执行的内存模型

// 本地方法栈 
和 Java 虚拟机栈的作用类似,区别是该区域为 JVM 提供使用 Native 方法的服务


// 堆内存(线程共享)
所有线程共享的一块区域,垃圾收集器管理的主要区域
- 每个方法在执行的时候,都会创建一个栈帧用于存储局部变量、操作数、动态链接、方法出口等信息。
- 每个方法调用都意味着一个栈帧在虚拟机栈中入栈到出栈的过程。

// 方法区(线程共享)
各个线程共享的一个区域,用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 线程共享区域,因此这是线程不安全的区域。
- 方法区也是一个可能会发生OutOfMemoryError的区域。
- 方法区存储的是从Class文件加载进来的静态变量、类信息、常量池以及编译器编译后的代码。

2 . 2 内存堆细节

1
2
3
4
5
6
7
8
9
10
11
java复制代码// 内存堆特点
- 存储的是我们new来的对象,不存放基本类型和对象引用。
- 由于创建了大量的对象,垃圾回收器主要工作在这块区域。
- 线程共享区域,因此是线程不安全的。
- 能够发生内存溢出,主要有OutOfMemoryError和StackOverflowError。

// 分代
Java堆区还可以划分为新生代和老年代,新生代又可以进一步划分为Eden区、Survivor 1区、Survivor 2区

// 注意比例 :
8:1:1 + 2:3

2 . 3 内存栈细节

  • 线程私有区域,每一个线程都有独享一个虚拟机栈,因此这是线程安全的区域。
  • 存放基本数据类型以及对象的引用。
  • 每一个方法执行的时候会在虚拟机栈中创建一个相应栈帧,方法执行完毕后该栈帧就会被销毁。
  • 方法栈帧是以先进后出的方式虚拟机栈的。每一个栈帧又可以划分为局部变量表、操作数栈、动态链接、方法出口以及额外的附加信息。
  • 这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(通常是递归导致的);JVM动态扩展时无法申请到足够内存则抛出OutOfMemoryError异常。

2 . 4 Java 内存堆和栈区别

  • 栈内存 : 用来存储基本类型的变量和对象的引用变量
  • 堆内存 : 用来存储Java中的对象,无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。

  • 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存;
  • 堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。

  • 如果栈内存没有可用的空间存储方法调用和局部变量,JVM 会抛出 java.lang.StackOverFlowError 错- 误;
  • 如果是堆内存没有可用的空间存储生成的对象,JVM 会抛出 java.lang.OutOfMemoryError 错误。

栈的内存要远远小于堆内存,如果你使用递归的话,那么你的栈很快就会充满。-Xss 选项设置栈内存的大小,-Xms 选项可以设置堆的开始时的大小。

2 . 6 HotSpot虚拟机

HotSpot 虚拟机将其物理上分为了2个部分 :

新生代(young generation)

  • 绝大多数最新被创建的对象会被分配到这里
  • 对象从这个区域消失的过程我们称之为”minor GC“

  • 新生代三空间
    • 一个伊甸园空间(Eden )
    • 两个幸存者空间(Survivor )

伊甸园Survivor-ASurvivor-BOld对象创建第一次GC对象不断累积根据算法移动到第二空间继续累计 ,GC几轮后剩下的放入老年代伊甸园Survivor-ASurvivor-BOld

对象描述

1
2
3
4
5
6
7
8
9
10
11
java复制代码// 老年代(old generation)
- 对象没有变得不可达,并且从新生代中存活下来,会被拷贝到这
- 对象从老年代中消失的过程,我们称之为”major GC“(或者”full GC“)

// card table
- 存在于老年代 ,512 byte,记录老年代对新生代的应用
- 由一个 write barrier

// 持久代( permanent generation )
- 又名方法区(method area)
- 保存类常量以及字符串常量

加快缓存分配的方式

  • bump-the-pointer
    • 跟踪在伊甸园空间创建的最后一个对象 ,放在顶部,下次创建查找该对象
  • TLABs(Thread-Local Allocation Buffers)
    • 该方案为每一个线程在伊甸园空间分配一块独享的空间,这样每个线程只访问他们自己的TLAB空间,再与bump-the-pointer技术结合可以在不加锁的情况下分配内存

三 . 垃圾清理

3 . 1 垃圾回收的起因

程序员无法自动完成系统的GC ,GC 一般在以下环境被创建

  • 大多数对象会很快变得不可达
  • 只有很少的由老对象(创建时间较长的对象)指向新生对象的引用

3 . 2 垃圾回收中的概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
java复制代码// stop-the-world
: Stop-the-world会在任何一种GC算法中发生
: Stop-the-world意味着 JVM 因为要执行GC而停止了应用程序的执行
: 当Stop-the-world发生时,除了GC所需的线程以外,所有线程都处于等待状态,直到GC任务完成
: GC优化很多时候就是指减少Stop-the-world发生的时间



// 为什么垃圾回收要分代:
- 不同的对象生命周期是不一样的 ,采用不同的收集方式,可以提高回收率

// 分代的方式 :
- 年轻代
- 老年代
- 持久代 : 用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响

// 新生代 GC 和老年代 GC
新生代 : 一个 Eden 区 + 两个 Survivor 区
老年代 : 默认新生代(Young)与老年代(Old)的比例的值为 1:2 , 默认的 Eden:from:to=8:1:1

新生代GC(MinorGC/YoungGC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 MinorGC 非常频繁,一般回收速度也比较快。
老年代GC(MajorGC/FullGC):指发生在老年代的 GC,出现了 MajorGC,经常会伴随至少一次的 MinorGC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 MajorGC 的策略选择过程)。MajorGC 的速度一般会比 MinorGC 慢 10 倍以上。


// 触发分代回收的方式
Scavenge GC 和 Full GC
- Scavenge GC : 新对象生成 , 并且在 Eden 申请空间失败 ,即触发
-


// 垃圾收集器
> 新生代收集器
- Serial 收集器
- ParNew 收集器
?- ParNew 收集器,是 Serial 收集器的多线程版。
- Parallel Scavenge 收集器

> 老年代收集器
- Serial Old 收集器
?- Serial Old 收集器,是 Serial 收集器的老年代版本。
- Parallel Old 收集器
?- Parallel Old 收集器,是 Parallel Scavenge 收集器的老年代版本。
- CMS 收集器

> 新生代 + 老年代收集器
- G1 收集器
- ZGC 收集器


//  G1 和 CMS 的区别
• CMS :并发标记清除。他的主要步骤有:初始收集,并发标记,重新标记,并发清除(删除)、重置。
• G1:主要步骤:初始标记,并发标记,重新标记,复制清除(整理)
• CMS 的缺点是对 CPU 的要求比较高。G1是将内存化成了多块,所有对内段的大小有很大的要求。
• CMS是清除,所以会存在很多的内存碎片。G1是整理,所以碎片空间较小。
• G1 和 CMS 都是响应优先把,他们的目的都是尽量控制 STW 时间。
• G1 和 CMS 的 Full GC 都是单线程 mark sweep compact 算法,直到 JDK10 才优化为并行的。
收集器 串行、并行or并发 新生代/老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 both 标记-整理+复制算法 响应速度优先 面向服务端应用,将来替换CMS

设置垃圾收集器

1
2
3
4
5
java复制代码XX:+UseSerialGC:设置串行收集器
XX:+UseParallelGC::设置并行收集器
XX:+UseParalledlOldGC:设置并行年老代收集器
XX:+UseConcMarkSweepGC:设置并发收集器
XX:+UseG1GC:G1收集器,Java9默认开启,无需设置

设置垃圾收集器的大小

1
2
3
4
5
6
java复制代码-XX:NewSize:设置年轻代最小空间大小
-XX:MaxNewSize:设置年轻代最大空间大小
-XX:PermSize:设置永久代最小空间大小
-XX:MaxPermSize:设置永久代最大空间大小
-XX:NewRatio:设置年轻代和老年代的比值。默认值-XX:NewRatio=2,表示年轻代与老年代比值为1:2,年轻代占整个堆大小的1/3
-XX:SurvivorRatio:设置年轻代中Eden区Survivor区的容量比值。默认值-XX:SurvivorRatio=8,表示Eden : Survivor0 : Survivor1 = 8 : 1 : 1

3 . 3 常见的垃圾收集器

3 .3 .1 Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器,它在进行垃圾收集的时候会暂停所有的工作线程,直到完成垃圾收集过程。下面是Serial垃圾收集器的运行示意图:

3 .3 .2 ParNew 收集器

1
arduino复制代码ParNew 垃圾收集器实则是Serial 垃圾收集器的多线程版本,这个多线程在于ParNew垃圾收集器可以使用多条线程进行垃圾回收。

3 .3 .3 Parallel Scavenge 收集器

Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为 吞吐量收集器

目的 : 即减少垃圾收集时间(就是每次垃圾收集时间短,但是收集次数多),让用户代码获得更长的运行时间

3. 3 .4 Serial Old 收集器

1
java复制代码Serial Old 收集器是Serial 收集器的老年代版本。其垃圾收集器的运行原理和Serial 收集器是一样的。

3 .3 .5 Parallel Old 收集器

1
java复制代码Parallel Old 收集器同样是Parallel Scavenge 收集器的老年代版本,支持多线程并发收集。

3 .3 .6 CMS 收集器

1
2
3
4
5
6
7
8
9
java复制代码CMS 垃圾收集器的运作过程相对前面几个垃圾收集器来说比较复杂,整个过程可以分为四个部分:

初始标记: 需要Stop The World,这里仅仅标记GC Roots能够直接关联的对象,所以速度很快。

并发标记: 从关联对象遍历整个GC Roots的引用链,这个过程耗时最长,但是却可以和用户线程并发运行。

重新标记: 修正并发时间,因为用户线程可能会导致标记产生变动,同样需要Stop The World。

并发清除: 清除已经死亡的对象。

3 .3 .7 Garbage First 收集器

Garbage First(简称G 1)收集器是垃圾收集器发展史上里程碑式的成果,主要面向服务端应用程序。另外G 1收集器虽然还保留新生代和老年代的概念,但是新生代和老年代不在固定,它们都是一系列区域的动态集合。

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的就对象以获取更好的收集效果。
  • 空间整合:G1从整体上来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,这意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。
  • 可预测的停顿:这是G1相对于CMS的另一大优势。

3 .4 常见得垃圾回收方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// 方式一 : 调用 system gc 方法 , 开发者手动调用该命令 , 触发 gc
System.gc()

// 方式二 : 调用 Runtime.getRuntime().gc() 方式 , 该方法实际上会 invoke system.gc()
Runtime.getRuntime().gc()

// 方式三 : Use jmap to force GC , 通过 jmap 命令执行 gc
// 该命令不能保证万无一失 , 如果 JVM 被占用导致 GC 无法执行会出现异常
jmap -histo:live 7544

// 方式四 : 使用 Jcmd 命令执行 GC
// 通过 Java diagnostic command (JCMD) JVM 诊断命令触发 GC
jcmd 7544 GC.run

// 方式五 : Use JConsole or Java Mission Control

3 . 5 垃圾回收的算法

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码// > 应用计数 
: 对一个对象有引用/移除 。 即添加/删除数量 , 垃圾回收会回收数量为 0 的对象

// > 标记清除
: 第一阶段从引用根节点开始标记所有被引用的对象
: 第二阶段遍历整个堆,把未标记的对象清除

// > 复制(Copying)
: 将算法的内存空间分为相等的两个部分,回收时,遍历当前区域,将使用的对象复制到另外的区域

// > 标记-整理(Mark-Compact):
: 第一阶段从根节点开始标记所有被引用对象
: 第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放

四 . 对象的创建

4 . 1 创建过程

1)检测类是否被加载
当虚拟机遇到 new 指令时,首先先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就执行类加载过程。

2)为对象分配内存
类加载完成以后,虚拟机就开始为对象分配内存,此时所需内存的大小就已经确定了。只需要在堆上分配所需要的内存即可。

具体的分配内存有两种情况:

  • 第一种情况是内存空间绝对规整
  • 第二种情况是内存空间是不连续的。
    • 对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可,这种方式被称为“指针碰撞”。
    • 对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为“空闲列表”。

多线程并发时会出现正在给对象 A 分配内存,还没来得及修改指针,对象 B 又用这个指针分配内存,这样就出现问题了。解决这种问题有两种方案:

  • 第一种,是采用同步的办法,使用 CAS 来保证操作的原子性。
  • 另一种,是每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),分配内存的时候再TLAB上分配,互不干扰。可以通过 -XX:+/-UseTLAB 参数决定。

3)为分配的内存空间初始化零值
对象的内存分配完成后,还需要将对象的内存空间都初始化为零值,这样能保证对象即使没有赋初值,也可以直接使用。

4)对对象进行其他设置
分配完内存空间,初始化零值之后,虚拟机还需要对对象进行其他必要的设置,设置的地方都在对象头中,包括这个对象所属的类,类的元数据信息,对象的 hashcode ,GC 分代年龄等信息。

5)执行 init 方法
执行完上面的步骤之后,在虚拟机里这个对象就算创建成功了,但是对于 Java 程序来说还需要执行 init 方法才算真正的创建完成,因为这个时候对象只是被初始化零值了,还没有真正的去根据程序中的代码分配初始值,调用了 init 方法之后,这个对象才真正能使用。

4 . 2 内存布局

1
2
3
4
5
6
java复制代码对象的内存布局包括三个部分:
- 对象头:对象头包括两部分信息。
• 第一部分,是存储对象自身的运行时数据,如哈希码,GC 分代年龄,锁状态标志,线程持有的锁等等。
• 第二部分,是类型指针,即对象指向类元数据的指针。
- 实例数据:就是数据。
- 对齐填充:不是必然的存在,就是为了对齐。

4 . 3 对象的访问定位

1
2
3
java复制代码句柄定位:Java 堆会画出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

直接指针访问:Java 堆对象的不居中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址。

4 . 4 对象死亡

引用计数算法: 为对象添加一个引用计数器,每当对象在一个地方被引用,则该计数器加1;每当对象引用失效时,计数器减1。但计数器为0的时候,就表白该对象没有被引用。

可达性分析算法: 通过一系列被称之为“GC Roots”的根节点开始,沿着引用链进行搜索,凡是在引用链上的对象都不会被回收。

// GC Root 对象 : 可达性的根对象

Java虚拟机栈中被引用的对象,各个线程调用的参数、局部变量、临时变量等。
方法区中类静态属性引用的对象,比如引用类型的静态变量。
方法区中常量引用的对象。本地方法栈中所引用的对象。
Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象。
被同步锁(synchronized)持有的对象。

4 . 6 类加载器

什么是类加载器
类加载器(ClassLoader),用来加载 Java 类到 Java 虚拟机中 , 一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件) , 类加载器,负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例

发生的时期

  • 1、遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类还没进行初始化,则需要先触发其初始化。
  • 2、使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类还没进行初始化,则需要先触发其初始化。
  • 3、当初始化了一个类的时候,如果发现其父类还没进行初始化,则需要先触发其父类的初始化。
  • 4、当虚拟机启动时,用户需要指定一个执行的主类,即调用其 #main(String[] args) 方法,虚拟机则会先初始化该主类。
  • 5、当使用 JDK7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

加载Class 的方式

  • 第一个阶段,加载(Loading),是找到 .class 文件并把这个文件包含的字节码加载到内存中。
  • 第二阶段,连接(Linking),又可以分为三个步骤,分别是字节码验证、Class 类数据结构分析及相应的内存分配、最后的符号表的解析。
  • 第三阶段,Initialization(类中静态属性和初始化赋值),以及Using(静态块的执行)等。

4 . 7 ClassLoader 详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码// Java 中有三个类加载器
1. Bootstrap CLassloder 
最顶层的加载类,主要加载核心类库,%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。另外需要注意的是可以通过启动jvm时指定-Xbootclasspath和路径来改变Bootstrap ClassLoader的加载目录。比如java -Xbootclasspath/a:path被指定的文件追加到默认的bootstrap路径中。我们可以打开我的电脑,在上面的目录下查看,看看这些jar包是不是存在于这个目录。 

2. Extention ClassLoader 
扩展的类加载器,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还可以加载-D java.ext.dirs选项指定的目录。 

3. AppClassLoader
加载当前应用的classpath的所有类


// classLoad 加载流程
Java 基于 Launcher 入口应用
- Launcher初始化了ExtClassLoader和AppClassLoader
-

// 知识点
1 父加载器不是父类
2 Bootstrap ClassLoader是由C/C++编写的

// 常用方法
- 获取父加载器 : cl.getParent() , cl.getParent().getParent()
- 通过指定的全限定类名加载class : loadClass()

1
2
3
4
5
6
7
8
9
10
11
java复制代码// 双亲委派
1 首先判断这个class是不是已经加载成功
2 当 class 未加载 , 先异常往根节点查找 , 是否上层加载器已经加载 (其中如果某个层已经加载 , 则直接返回)
3 当到 Bootstrap classloader 仍然未加载 , 则由 Bootstrap classloader 到指定的路径查找 , 如果没有查找到 ,则由子加载器继续到其对应路径查找
4 到此时仍然没有查找到 ,则返回异常

// 流程 TODO :

// 思考 :
加载对象的时候是从顶层向下 , 查找对象是由底层向上
业务中我们是能够定义多个 Classloader , 使用双亲委派避免不知道在哪个 classLoader 中查找 , 也避免重复加载的问题

4 . 8 class 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码// Class 加载流程 
1. .java 文件编译后 , 生成一个class文件
2. classloader通过相关的规则初次找到这个class
3. 然后会读取class的头文件,包括以下几种数据
a. 0xCAFEBABE:判断是否为Java编译
b. 50 , 0:判断版本号
4. String, ArrayList分别有不同层次的loader加载,最顶层的叫Bootstrap Classloader , 下一次级叫Extension Classloader,最底层App Classloade
5. 接着class会被加载到方法区 , 在堆中new 出的该class类的对象来确认class是否被加载
6. 每个class会有局部变量区,还有一个操作数栈 , 线程就会按照流程执行,例如取出局部变量区的数据,放入栈中,最后运行后变成一个数后重新放入
7. 接中从栈中取出结果,重新放入变量区
8. 而线程也不一定只有一个工作台,也可能有多个,但是只在最上面工作(多线程情况),这每个工作台叫栈帧,而多个工作台就是方法调用方法的结果


// Java 对象头
GC分代信息,锁信息,哈希码,指向Class类元信息的指针
Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)
- Klass Point 是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
- Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bits)。但是如果对象是数组类型,则需要三个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。


// Java 对象实例数据
实例数据部分是对象真正存储的有效信息

// Java 对象对齐填充
虚拟机规范要求对象大小必须是8字节的整数倍

Java 对象头的存储结构 32 位 TODO : 待完善

好文推荐@ 从一个class文件深入理解Java字节码结构_四月葡萄的博客-CSDN博客_java字节码

image.png

查看字节码的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
java复制代码// 方法一 : Java 基本工具类
- 查看基本的字节码 : javap java.lang.Object
- 查看基本成员 : javap -p
- 查看详细信息 : javap -v
- 反汇编整个类 : javap -c

// 方法二 : 使用 ASM 查询
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>8.0.1</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-util</artifactId>
<version>8.0.1</version>
</dependency>

try {
ClassReader reader = new ClassReader("java.lang.Object");
StringWriter sw = new StringWriter();
TraceClassVisitor tcv = new TraceClassVisitor(new PrintWriter(System.out));
reader.accept(tcv, 0);
} catch (IOException e) {
e.printStackTrace();
}

// 方法三 : BCEL
<dependency>
<groupId>org.apache.bcel</groupId>
<artifactId>bcel</artifactId>
<version>6.5.0</version>
</dependency>

try {
JavaClass objectClazz = Repository.lookupClass("java.lang.Object");
System.out.println(objectClazz.toString());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}

// 方法四 : Javassist
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.27.0-GA</version>
</dependency>

try {
ClassPool cp = ClassPool.getDefault();
ClassFile cf = cp.get("java.lang.Object").getClassFile();
cf.write(new DataOutputStream(new FileOutputStream("Object.class")));
} catch (NotFoundException e) {
e.printStackTrace();
}

// 方法五 : Jclasslib (IDEA 插件)

4 . 9 对象在JVM 中的表示 – OOP-Klass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码HotSpot 通过 OOP-Klass 模型来在虚拟机中表示一个对象 , 这里的 OOP 指的是 Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而 Klass 则包含元数据和方法信息,用来描述Java类。

作用 :
避免让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klass和oop,其中oop中不含有任何虚函数,而Klass就含有虚函数表,可以进行method dispatch。

Klass : Java类在HotSpot中的c++对等体,用来描述Java类 , 在加载过程中创建
- 实现语言层面的Java类
- 实现Java对象的分发功能

OOP : 在Java程序运行过程中new对象时创建的 , 包含以下部分
- instanceOopDesc,也叫对象头
- Mark Word,主要存储对象运行时记录信息,如hashcode, GC分代年龄,锁状态标志,线程ID,时间戳等
- 元数据指针,即指向方法区的instanceKlass实例
- 实例数据

五 . GC 监控

5 .1 什么时 GC 监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码GC 监控是指监控 JVM 执行 GC	的过程
例如 :
> 何时一个新生代被移动到老年代,以及其中被花费的时间
> stop the world 何时发生,执行了多长时间

> GC 访问的接口 : GUI / CUI 两大类
: cUI GC 监控方法使用的独立的 jstat 的 CUI 应用
: cUI 或者在启动的时候选择JVM 参数 verbosegc
: GUI GC 由一个单独的图形化界面完成 : jconsole ,jvisualvm , Visual GC


jstat :
参数名称见附录

-verbosegc : 启动 Java 应用时可指定

5 .2 常见的 GC 监控工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码• jps :虚拟机进程状况工具
JVM Process Status Tool ,显示指定系统内所有的HotSpot虚拟机进程。
-q:忽略输出的类名、Jar名以及传递给main方法的参数,只输出pid。
-m:输出传递给main方法的参数,如果是内嵌的JVM则输出为null。
-l:输出完全的包名,应用主类名,jar的完全路径名
-v:输出传给jvm的参数
-V:输出通过标记的文件传递给JVM的参数(.hotspotrc文件,或者是通过参数-XX:Flags=指定的文件)。
-J 用于传递jvm选项到由javac调用的java加载器中,

• jstat :虚拟机统计信息监控工具
JVM statistics Monitoring ,是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。 常见的用法包括类的加载及卸载情况 , 查看新生代、老生代及持久代的容量及使用情况 , 查看新生代、老生代及持久代的垃圾收集情况,包括垃圾回收的次数及垃圾回收所占用的时间 , 查看新生代中Eden区及Survior区中容量及分配情况

• jinfo :Java 配置信息工具
JVM Configuration info ,这个命令作用是实时查看和调整虚拟机运行参数。

• jmap :Java 内存映射工具
JVM Memory Map ,命令用于生成 heap dump 文件。

• jhat :虚拟机堆转储快照分析工具
JVM Heap Analysis Tool ,命令是与 jmap 搭配使用,用来分析 jmap 生成的 dump 文件。jhat 内置了一个微型 的HTTP/HTML 服务器,生成 dump 的分析结果后,可以在浏览器中查看。

• jstack :Java 堆栈跟踪工具
Java Stack Trace ,用于生成 Java 虚拟机当前时刻的线程快照。

• HSDIS :JIT 生成代码反编译

// Java 自带
• JConsole :Java 监视与管理控制台
Java Monitoring and Management Console 是从 Java5 开始,在 JDK 中自带的 Java 监控和管理控制台,用于对 JVM 中内存,线程和类等的监控。
• VisualVM :多合一故障处理工具
JDK 自带全能工具,可以分析内存快照、线程快照、监控内存变化、GC变化等。
特别是 BTrace 插件,动态跟踪分析工具。

// 其他
• MAT :内存分析工具
• [GChisto](GC 日志分析工具 —— GChisto) :一款专业分析 GC 日志的工具。


// JMC : Java Mission Control
-> 完整的图形化界面
-> 提供对象查看

5.3 监控常用命令

1
2
3
4
java复制代码// 获取 Java 程序使用的内存
Runtime#freeMemory() 方法,返回剩余空间的字节数。
Runtime#totalMemory() 方法,总内存的字节数。
Runtime#maxMemory() 方法,返回最大内存的字节数。

5.4 GC 分析方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
java复制代码// --------------- jconsole 使用
- 控制台直接输入 : jconsole
- 1 选择需要调试的本地连接 , 点击连接
- 2 选择远程连接 , 输入用户名 , 口令连接

// -------------- jvisualvm 使用
- 找到 JDK 的安装目录 , 点击运行 jvisualvm.exe
- 右侧直接选择运行中的应用

// --------------- jstat 使用 (命令行)
jstat <option> [-t] [-h] <pid> <interval> <count>
  参数解释:
option 可以从下面参数中选择
-class 显示ClassLoad的相关信息;
-compiler 显示JIT编译的相关信息;
-gc 显示和gc相关的堆信息;
-gccapacity    显示各个代的容量以及使用情况;
-gccause 显示垃圾回收的相关信息(通-gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
-gcnew 显示新生代信息;
-gcnewcapacity 显示新生代大小和使用情况;
-gcold 显示老年代和永久代的信息;
-gcoldcapacity 显示老年代的大小;
-gcpermcapacity 显示永久代的大小;
-gcutil   显示垃圾收集信息;
-printcompilation输出JIT编译的方法信息;
-t 可以在打印的列加上Timestamp列,用于显示系统运行的时间
-h   可以在周期性数据数据的时候,可以在指定输出多少行以后输出一次表头
interval 执行每次的间隔时间,单位为毫秒
count 用于指定输出多少次记录,缺省则会一直打印

案例 : 
|- : Jstat -cpmpiler pid
|- 查看pid为23814的ClassLoad相关信息,每秒钟打印一次,总共打印5次 : jstat -gc pid 1000 5
|- 显示各个代的容量的信息 : jstat -gccapacity pid
|- 显示最近一次GC的原因 : jstat -gccause pid
|- 显示新生代的详细信息 : jstat -gcnew pid:
|- 输出新生代各个区的详细信息 : jstat -gcnewcapacity pid
|- 显示老年代GC的详细情况 : jstat -gcold pid
|- 输出老年代的详细信息 : jstat -gcoldcapacitp pid
|- 查看每个代区域使用的百分比情况 : jstat -gcutil pid

// ------------------- jmap 使用
jmap [option] vmid

-dump : 生成Java堆转储快照
-heap:显示Java堆详细信息
-histo:显示堆中对象统计信息

案例 :
|- 使用jmap 生成快照文件 : jmap -dump:format=b,file=jsconsole.bin 7020
|- 生成一个正常运行的jconsole的快照的实例 : jps
|- 查看堆栈信息 : jmap -heap pid
|- 使用jmap 生成快照文件 : jmap -dump:format=b,file=jsconsole.bin 7020

// --------------------- jhat 使用
Step 1 : 导出栈
jmap -dump:live,file=a.log pid
Step 2 : 分析堆文件
jhat -J-Xmx512M a1.log
Step 3 : 查看
http://ip:7000/
Step 4 : 使用 SQL 查询
select <javascript expression to select>
[from [instanceof] <class name> <identifier>]
[where <javascript boolean expression to filter>]

(1)class name是java类的完全限定名,如:java.lang.String, java.util.ArrayList, [C是char数组, [Ljava.io.File是java.io.File[]
(2)类的完全限定名不足以唯一的辨识一个类,因为不同的ClassLoader载入的相同的类,它们在jvm中是不同类型的
(3)instanceof表示也查询某一个类的子类,如果不明确instanceof,则只精确查询class name指定的类
(4)from和where子句都是可选的
(5)java域表示:obj.field_name;java数组表示:array[index]

案例 :
(1)查询长度大于100的字符串
select s from java.lang.String s where s.count > 100
(2)查询长度大于256的数组
select a from [I a where a.length > 256
(3)显示匹配某一正则表达式的字符串
select a.value.toString() from java.lang.String s where /java/(s.value.toString())
(4)显示所有文件对象的文件路径
select file.path.value.toString() from java.io.File file
(5)显示所有ClassLoader的类名
select classof(cl).name from instanceof java.lang.ClassLoader cl
(6)通过引用查询对象
select o from instanceof 0xd404d404 o

https://www.cnblogs.com/baihuitestsoftware/articles/6406271.html

// ------------- jvm jinfo
|- 查看 JVM 参数 : jinfo -flags process_id
|- 查看java系统参数 : jinfo -sysprops process_id

5.5 压测工具扩展

1
2
3
4
5
6
7
8
9
10
11
java复制代码// 常用的压测工具
1 LoadRunner : 预测系统行为和性能的负载测试工具
2 Apache JMeter : 开源压测产品
3 NeoLoad : 负载和性能测试工具
4 WebLOAD : 来自Radview公司的负载测试工具,它可被用以测试系统性能和弹性,也可被用于正确性验证
5 阿里云PTS : 一个SaaS性能测试平台,具有强大的分布式压测能力
6 Loadstorm : 一款针对Web应用的云端负载测试工具,通过模拟海量点击来测试Web应用在大负载下的性能表现
7 CloudTest : 一个集性能和功能测试于一体的综合压力测试云平台
8 Load impact : 一款服务于DevOps的性能测试工具,支持各种平台的网站、Web应用、移动应用和API测试

// JMeter 使用

5.6 Jstack 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
java复制代码> Step 1 : 拿到 pid
ps -ef | grep java

> Step 2 : 查看资源进程
top -Hp 30275

printf "%x\n" 3440


> 简单使用
jstack 30275

> 查看指定进程
printf "%x\n" 17880
jstack 17880|grep 45d8 -A 30

// 查看 TimeWait


// Windows 版本
netstat -ano |findstr "80" windows
netstat -an | find "TIME_WAIT" /C

// jstack 统计线程数
jstack -l 28367 | grep 'java.lang.Thread.State' | wc -l


// jstack

Usage:
jstack [-l] <pid>
(to connect to running process) 连接活动线程
jstack -F [-m] [-l] <pid>
(to connect to a hung process) 连接阻塞线程
jstack [-m] [-l] <executable> <core>
(to connect to a core file) 连接dump的文件
jstack [-m] [-l] [server_id@]<remote server IP or hostname>
(to connect to a remote debug server) 连接远程服务器

Options:
-F to force a thread dump. Use when jstack <pid> does not respond (process is hung)
-m to print both java and native frames (mixed mode)
-l long listing. Prints additional information about locks
-h or -help to print this help message

5.7 JOL 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码JOL:查看Java 对象布局、大小工具

<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>put-the-version-here</version>
</dependency>

static Object generate() {
Map<String, Object> map = new HashMap<>();
map.put("a", new Integer(1));
map.put("b", "b");
map.put("c", new Date());

for (int i = 0; i < 10; i++) {
map.put(String.valueOf(i), String.valueOf(i));
}
return map;
}

查看对象内部信息: ClassLayout.parseInstance(obj).toPrintable()
查看对象外部信息:包括引用的对象:GraphLayout.parseInstance(obj).toPrintable()
查看对象占用空间总大小:GraphLayout.parseInstance(obj).totalSize()

5.8 Thread Dump

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
java复制代码Thread Dump是非常有用的诊断Java应用问题的工具。每一个Java虚拟机都有及时生成所有线程在某一点状态的thread-dump的能力,虽然各个 Java虚拟机打印的thread dump略有不同,但是大多都提供了当前活动线程的快照,及JVM中所有Java线程的堆栈跟踪信息,堆栈信息一般包含完整的类名及所执行的方法,如果可能的话还有源代码的行数。

1. 查找内存泄露,常见的是程序里load大量的数据到缓存;
2. 发现死锁线程;


// Linux 抓取 Dump 的方式 (20810 是 jstack 在Java 目录下 )
jstack -l 20810 | tee -a /opt/jstack.log

// 简单学习 :
// 虚拟机信息
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.251-b08 mixed mode):

// 线程info信息块:
// 线程名称 - #36 - 线程类型 (daemon) - 优先级 (prio)
// tid : JVM 线程ID
// nid : 对应系统线程id
// 线程状态:in Object.wait().
// 起始栈地址:[0xae77d000]
"Attach Listener" #36 daemon prio=9 os_prio=0 tid=0x00007f5fec001000 nid=0x6658 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE

Locked ownable synchronizers:
- None

// 堆栈信息
线程状态 - java.lang.Thread.State: WAITING (parking)
线程抛出节点 - at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000e4e7bdf8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:107)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:748)

// 方案
cpu飙高,load高,响应很慢 --> 单请求 dump 多次
查找占用cpu最多的线程信息 --> 对对应的线程进行 dump , 先 top 查询对应 id
cpu使用率不高但是响应很慢 --> 进行dump,查看是否有很多thread struck在了i/o、数据库等地方,定位瓶颈原因
请求无法响应 --> 多次dump,对比是否所有的runnable线程都一直在执行相同的方法

// 常见分析
死锁 , 热锁

5.9 GC日志详情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码// 使用方法 : 
-XX:+PrintGCDetails

// 参数含义 : @ https://www.cnblogs.com/xuwenjin/p/13092857.html
GC:表示进行了一次Minor GC,即从年轻代空间(包括 Eden 和 Survivor 区域)回收内存

Allocation Failure:在年轻代中没有足够的空间能够存储新的数据

Full GC (Ergonomics):表示进行了一次Full GC,即清理整个堆空间(包含年轻代和老年代)

PSYoungGen: 1024K->1024K(1536K):垃圾回收器是Paralle Scavenge,年轻代区GC前->GC后该区域已使用量,后面的1536表示该区域总量

ParOldGen: 4032K->4032K(4096K):老年代区,GC前->GC后该区域已使用量,后面的4096表示该区域总量

5056K->5056K(5632K):GC前 -> GC后Java堆的使用量,后面的5632表示Java堆总量

Metaspace: 3117K->3117K(1056768K):JDK8中取消了永久代,新增了一个叫元空间(Metaspace)的区域,对应的还是JVM规范中的方法区(主要存放一些class和元数据的信息),该数据表示该区GC前后使用量

0.0214352 secs:暂停STW 时间,即GC的时间

Times: user=0.02 sys=0.01, real=0.02 secs:更为详细的时间占比统计

六 . GC 优化

6 . 1 GC 优化的前提

1
2
3
4
5
6
7
java复制代码> GC 优化永远是最后一项任务

> 原则 :
> 将转移到老年代的对象数量降到最少
:调整新生代空间的大小。
> 减少 Full GC 的执行时间
: 你需要将老年代空间设定为一个“合适”的值

6 . 2 GC 优化的方案

1
2
3
4
java复制代码> 使用  StringBuilder 或者StringBuffer 来替代String
> 尽量少的输出日志

GC 优化考虑的参数

6 . 3 GC 优化需要考虑的参数

image.png

6 . 4 GC类型可选参数

image.png

6 . 5 GC 优化过程

1
2
3
4
java复制代码1 > 监控 GC 状态
2 > 分析监控结果 , 考虑是否需要GC
3 > 调整 GC 类型 , 分配存储空间
4 > 分析结果

6 . 6 内存溢出的情况及分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码> 1 堆栈溢出
- java.lang.OutOfMemoryError: ......java heap space.....
- 看到heap相关的时候就肯定是堆栈溢出 , 适当调整 -Xmx和-Xms
- 访问量太多并且每个访问的时间太长或者数据太多,导致数据释放不掉
- java.lang.OutOfMemoryError:GC over head limit exceeded -- 系统处于高频的GC状态,而且回收的效果依然不佳
> 2 PermGen的溢出
- java.lang.OutOfMemoryError: PermGen space
- 系统的代码非常多或引用的第三方包非常多、或代码中使用了大量的常量、或通过intern注入常量、或者通过动态代码加载等方法,导致常量池的膨胀
-XX:PermSize和-XX:MaxPermSize的大小

> 3 ByteBuffer中的allocateDirect() 溢出
- java.lang.OutOfMemoryError: Direct buffer memory
-直接或间接使用了ByteBuffer中的allocateDirect方法的时候,而不做clear的时候就会出现类似的问题
-XX:MaxDirectMemorySize

> 4 java.lang.StackOverflowError
- java.lang.StackOverflowError
- -Xss太小了,我们申请很多局部调用的栈针等内容是存放在用户当前所持有的线程中的

> 5 java.lang.OutOfMemoryError: unable to create new native thread
- 说明除了heap以外的区域,无法为线程分配一块内存区域了,这个要么是内存本身就不够,要么heap的空间设置得太大了

> 6 java.lang.OutOfMemoryError: request {} byte for {}out of swap
- 一般是由于地址空间不够而导致

6 . 7 Full GC 原因分析及解决

1
2
3
4
5
6
java复制代码// 原因 : 
1 . 应用程序创建了太多无法快速回收的对象。
2 . 当堆被分割时,即使有很多空闲空间,在老代中直接分配也可能失败

https://blog.gceasy.io/2020/06/02/simple-effective-g1-gc-tuning-tips/
https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html

七 小知识点

7 . 1 Jar 相关

1
java复制代码TODO

7 . 2 CPU

1
java复制代码TODO

八 其他

# 32 位 JVM 和 64 位 JVM 的最大堆内存分别是多少

1
2
java复制代码理论上说上 32 位的 JVM 堆内存可以到达 2^32,即 4GB
4 位 JVM 允许指定最大的堆内存,理论上可以达到 2^64

# 直接内存(堆外内存)

1
2
3
4
5
6
java复制代码> 直接内存(Direct Memory),并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中农定义的内存区域
> NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配堆外内存,然后通脱一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作

// 对比
直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显
直接内存 IO 读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显

# 其他工具

1
java复制代码> GCEasy :

# JMC 分析流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
java复制代码// Step 1 : 开启指定应用的飞行记录

// Step 2 : 分析模块 , 飞行记录提供了以下几个模块
> 一般信息 : | 概述 | JVM 信息 | 系统属性 | 记录
- CPU 占用率 : 可以判断是否CPU占满导致的缓慢
- 堆使用率 : 内存使用情况会导致垃圾收集的频率 , Redis 的使用(AOF/RDB持久化异常) ,
- JVM 信息 : 可以了解到当前使用的虚拟机类型(不同类型虚拟机会使用不同的回收策略 , 以及使用的JDK , 配置的 JVM 参数等)

> 内存 : | 概述 | 垃圾收集 | GC 时间 | GC 配置 | 分配 | 对象统计信息
- 概述 :
- GC 配置 : 包含 GC 的配置信息 , 以及对应的收集器类型
- GC 统计时间 :
- 垃圾收集 : 包含垃圾收集的次数和消耗的时间
-> 垃圾收集的频率是否正常 , 是否过于频繁
-> 每次消耗的时候会不会太长 ?stop-world 后会影响其他的运行
- GC 时间 : 该时间为不同年龄代的时间
- GC 配置 : 配置的 GC 线程数 , 堆内存等配置详情
- 分配 : 主要是 TLAB 的分配情况 ,

> 代码 : | 概述 | 热点方法 | 调用树 | 异常错误 | 编译 | 类加载
- 热点方法 : 判断代码中对相关方法的调用是否合理(对应类的堆会不会过大 , 对象会不会过多)
- 热点方法 : 判断常用的对象会不会有多线程风险及死锁风险 , 是否效率过低
- 调用树 : 通过调用树追溯问题的根源
- 异常错误 : 判断是否存在异常

> 线程 : | 概述 | 热点线程 | 争用 | 等待时间 | 线程转储 | 锁定实例
- 判断死锁
- 判断线程的销毁情况
- 判断是否有切换线程带来的损失 (频繁切换) , 热锁的情况
- 判断线程是否合理使用了线程池等工具

> IO : | 概述 | 文件读写 | 套接字读写 |
- 这个模块可以有效的分析是否为文件读写时间导致的延迟或者套接字访问导致的系统缓慢

> 系统信息及环境变量 , 略

> 事件 : 发生的事件比例 , 包括 Java Thread Park , Java Thread start , Java Thread end 等

# skywalking 分析流程

1
2
3
4
5
6
7
8
9
10
java复制代码skywalking 是链路分析工具 , 是很好的辅助工具 , 能快速的分析瓶颈点

// 使用方式(不过多简述 , 很多) , 注意需要点击一下刷新才会出数据
-javaagent:D:\java\plugins\shywalking\agent82\skywalking-agent.jar

// 常用用法:
- 仪表盘 : 用于查看各服务器状态
- 拓扑图 : 用于分析服务器的结构是否合理 (redis 服务器 , mysql 服务器 , 等等其他的)
- 追踪 : 可以快速判断慢SQL , 慢 接口
- 性能分析 : (创建的端点是追踪里面的端点,点击分析后可以直接追踪到对应的代码行)

九 优化的流程

9.1 压测流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
java复制代码> 对连接数进行优化 , 包括
- Mysql 连接数
- redis 连接数
- Mysql 连接池数量 (连接池数量不是越大月好)
- Redis 连接池数量
- tomcat 连接数
- TCP 连接数 (包括指定端口 , 随机端口 , 端口总数限制)
- LDAP 连接数
- OpenFile 连接数

> 对 GC 进行分析
- 1 查看 CPU 使用情况 : top
- 2 查看指定进程的使用 : top -Hp [进程ID]
- 3 分析当前进程栈 : jstack [进程ID] > jstack_01
- 4 查看 GC 情况 : jstat -gcutil [进程id] 1000
- 5 查看堆内存 : jmap -histo [进程id] > jmap.txt
- 6 打印堆内存快照 : jmap -dump:format=b,file=aa.bin 1232134

> 对 GC 进行优化
// 80 时进行 GC
- -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly
// 保留 GC log
- -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:gc.log

> 负载均衡
- 判断负载的方式是轮询还是压力

// 优化 新生代容积
-Xmn350M -> -Xmn800M
-XX:SurvivorRatio=4 -> -XX:SurvivorRatio=8
-Xms1000m ->-Xms1800m

// 优化 metaspace
-Xmn350M -> -Xmn800M
-Xms1000M ->1800M
-XX:MetaspaceSize=200M
-XX:CMSInitiatingOccupancyFraction=75

附录 :

常见的 JVM 参数

Jstat 参数名称

描述 参数名称
输出每个堆区域的当前可用空间以及已用空间(伊甸园,幸存者等等),GC执行的总次数,GC操作累计所花费的时间。 gc
输出每个堆区域的最小空间限制(ms)/最大空间限制(mx),当前大小,每个区域之上执行GC的次数。(不输出当前已用空间以及GC执行时间)。 gccapactiy
输出-gcutil提供的信息以及最后一次执行GC的发生原因和当前所执行的GC的发生原因 gccause
输出新生代空间的GC性能数据 gcnew
输出新生代空间的大小的统计数据。 gcnewcapacity
输出老年代空间的GC性能数据。 gcold
输出老年代空间的大小的统计数据。 gcoldcapacity
输出持久带空间的大小的统计数据。 gcpermcapacity
输出每个堆区域使用占比,以及GC执行的总次数和GC操作所花费的事件。 gcutil
列 说明 Jstat参数
S0C 输出Survivor0空间的大小。单位KB。 -gc -gccapacity -gcnew -gcnewcapacity
S1C 输出Survivor1空间的大小。单位KB。 -gc -gccapacity -gcnew -gcnewcapacity
S0U 输出Survivor0已用空间的大小。单位KB。 -gc -gcnew
S1U 输出Survivor1已用空间的大小。单位KB。 -gc -gcnew
EC 输出Eden空间的大小。单位KB。 -gc -gccapacity -gcnew -gcnewcapacity
EU 输出Eden已用空间的大小。单位KB。 -gc -gcnew
OC 输出老年代空间的大小。单位KB。 -gc -gccapacity -gcold -gcoldcapacity
OU 输出老年代已用空间的大小。单位KB。 -gc -gcold
PC 输出持久代空间的大小。单位KB。 -gc -gccapacity -gcold -gcoldcapacity -gcpermcapacity
PU 输出持久代已用空间的大小。单位KB。 -gc -gcold
YGC 新生代空间GC时间发生的次数。 -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
YGCT 新生代GC处理花费的时间。 -gc -gcnew -gcutil -gccause
FGC full GC发生的次数。 -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
FGCT full GC操作花费的时间 -gc -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
GCT GC操作花费的总时间。 -gc -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
NGCMN 新生代最小空间容量,单位KB。 -gccapacity -gcnewcapacity
NGCMX 新生代最大空间容量,单位KB。 -gccapacity -gcnewcapacity
NGC 新生代当前空间容量,单位KB。 -gccapacity -gcnewcapacity
OGCMN 老年代最小空间容量,单位KB。 -gccapacity -gcoldcapacity
OGCMX 老年代最大空间容量,单位KB。 -gccapacity -gcoldcapacity
OGC 老年代当前空间容量制,单位KB。 -gccapacity -gcoldcapacity
PGCMN 持久代最小空间容量,单位KB。 -gccapacity -gcpermcapacity
PGCMX 持久代最大空间容量,单位KB。 -gccapacity -gcpermcapacity
PGC 持久代当前空间容量,单位KB。 -gccapacity -gcpermcapacity
PC 持久代当前空间大小,单位KB -gccapacity -gcpermcapacity
PU 持久代当前已用空间大小,单位KB -gc -gcold
LGCC 最后一次GC发生的原因 -gccause
GCC 当前GC发生的原因 -gccause
TT 老年化阈值。被移动到老年代之前,在新生代空存活的次数。 -gcnew
MTT 最大老年化阈值。被移动到老年代之前,在新生代空存活的次数。 -gcnew
DSS 幸存者区所需空间大小,单位KB。 -gcnew

虚拟机常见配置快查

说明 命令
开启 GC Log (java8) -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:{file-path}
开启 GC Log (java9) -Xlog:gc*:file={file-path}

致谢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码// 此篇笔记是一个总结分析的笔记 , 时间周期较长 , 很多知识点已经难以追溯出处 , 如果此处遗漏了某位道友 ,敬请谅解

Java 技术驿站 , 一系列死磕看的相当爽
http://cmsblogs.com/?p=5140

CSDN
http://blog.csdn.net/linxdcn/article/details/72896616

芋道源码 , 很不错的源码博客
http://www.iocoder.cn/

掘金老哥
https://juejin.im/post/5c31dca7e51d45524975d046

CSDN
https://blog.csdn.net/briblue

以及所有对该文章有所帮助的表示感谢

更新日志

V20210817 : 改变整体风格
V20210818 : 补充文档

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android 开源库

发表于 2021-02-18

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

当一个开发者或团队的水平积累到一定程度,会有自内向外输出价值的需求。在这个专栏里,小彭将为你分享 Android 方向主流开源组件的实现原理,包括网络、存储、UI、监控等。

本文是 Android 开源库系列的第 9 篇文章,完整文章目录请移步到文章末尾~

前言

  • 在 Android 开发中,EventBus 事件总线机制十分常用;
  • 今天,我将整理 EventBus 详细的使用教程,追求简单易懂又不失深度。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

目录


  1. EventBus 概述

  • 定义:一套 Android / Java 事件订阅 / 发布框架,由 greenrobot 团队开源。

  • 作用:在组件 / 线程间通信的场景中,将数据或事件传递给对应的订阅者。

  • 为什么要使用 EventBus (特点)?
    在 Android 组件 / 线程间通信的应用场景中,EventBus 比传统的接口监听、Handler、Executors、LocalBroadcastManager 更简洁可靠,具体描述如下:
    • 1、使用事件总线框架,实现事件发布者与订阅者松耦合;
    • 2、提供了透明线程间通信,隐藏了发布线程与订阅线程间的线程切换。

  • EventBus 相关概念
    关于EventBus机制的相关概念如下:


  1. 使用步骤

在分析 EventBus 的使用原理之前,我们先来介绍下 EventBus 的使用步骤。

2.1 步骤1:添加依赖

  • 在 module 级build.gradle中添加依赖:
1
2
3
4
bash复制代码dependencies {
def eventbus_version = '3.2.0'
implementation "org.greenrobot:eventbus:$eventbus_version"
}
  • 使用编译时索引时,还需要依赖 注解处理工具,注意:纯 Java 项目和 Kotlin 使用的注解处理工具不同:
    • Java 项目使用annotationProcessor
    • Kotlin 项目使用kapt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码// Java:
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments = [ eventBusIndex : 'com.have.a.good.MyEventBusAppIndex' ]
}
}
}
}

dependencies {
def eventbus_version = '3.2.0'
implementation "org.greenrobot:eventbus:$eventbus_version"
annotationProcessor "org.greenrobot:eventbus-annotation-processor:$eventbus_version"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt' // ensure kapt plugin is applied

dependencies {
def eventbus_version = '3.2.0'
implementation "org.greenrobot:eventbus:$eventbus_version"
kapt "org.greenrobot:eventbus-annotation-processor:$eventbus_version"
}

kapt {
arguments {
arg('eventBusIndex', 'com.have.a.good.MyEventBusAppIndex')
}
}

2.2 步骤2:准备订阅者

订阅者需要实现订阅方法,并使用@Subscribe注解修饰,具体要求如下:

举例:

1
2
3
4
csharp复制代码@Subscribe(threadMode = ThreadMode.MAIN)
public void onMessageEvent(MessageEvent event) {
Toast.makeText(getActivity(), event.message, Toast.LENGTH_SHORT).show();
}

@Subscribe 注解参数中,threadMode参数决定了使用的线程模型,目前一共有五种:

2.3 步骤3:注册与注销

在发布事件之前,需要先 注册订阅者。而在订阅者生命周期结束时,需要 注销订阅者。

举例:

1
2
3
4
5
6
7
8
9
10
11
typescript复制代码@Override
public void onStart() {
super.onStart();
EventBus.getDefault().register(this);
}

@Override
public void onStop() {
EventBus.getDefault().unregister(this);
super.onStop();
}

2.4 步骤4:发布事件

注册订阅者之后,就可以发布事件了,目前有两种事件:

  • 调用EventBus#post(Object)发布普通事件
  • 调用EventBus#postSticky(Object)发布粘性事件

举例:

1
arduino复制代码EventBus.getDefault().post(new MessageEvent("Hello everyone!"));

粘性事件 的特点如下:

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
scss复制代码1、订阅
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEvent(MessageEvent event) {
textField.setText(event.message);
}

2、发布
EventBus.getDefault().postSticky(new MessageEvent("Hello everyone!"));

3、获取粘性事件
MessageEvent stickyEvent = EventBus.getDefault().getStickyEvent(MessageEvent.class);
if(stickyEvent != null) {
4、移除粘性事件
EventBus.getDefault().removeStickyEvent(stickyEvent);
// do something.
}
5、移除粘性事件
MessageEvent stickyEvent = EventBus.getDefault().removeStickyEvent(MessageEvent.class);
if(stickyEvent != null) {
// do something.
}

  1. 编译时索引

EventBus 3.x相较于EventBus 2.x最大的改良就是 编译时索引,注解生成器的源码可查看:EventBus 注解处理器源码,具体描述如下:

为了生成编译时索引,首先需要在build.gradle中配置索引文件,例如:

1
2
3
4
5
javascript复制代码kapt {
arguments {
arg('eventBusIndex', 'com.have.a.good.MyEventBusAppIndex')
}
}

编译时,注解处理器 将解析@Subscribe注解修饰的方法,生成 索引类MyEventBusAppIndex.java。你需要做的是在运行时构建时添加索引,例如:

1
2
3
ini复制代码EventBus eventBus = EventBus.builder()
.addIndex(new MyEventBusAppIndex())
.build();

需要注意:索引类配置只对当前 module 有效,因此需要在每个包含订阅者的 module 级build.gradle中添加索引类配置,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码// App module 
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt' // ensure kapt plugin is applied

kapt {
arguments {
arg('eventBusIndex', 'com.have.a.good.MyEventBusAppIndex')
}
}

dependencies {
...
kapt "org.greenrobot:eventbus-annotation-processor:3.2.0"
implementation project(path: ':base')
}

// Lib module
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt' // ensure kapt plugin is applied

kapt {
arguments {
arg('eventBusIndex', 'com.have.a.good.MyEventBusLibIndex')
}
}

dependencies {
...
api 'org.greenrobot:eventbus:3.2.0'
kapt "org.greenrobot:eventbus-annotation-processor:3.2.0"
implementation project(path: ':base')
}

以上配置将生成两个索引类文件,MyEventBusAppIndex.java只包含App Module中的订阅者索引,而MyEventBusLibIndex.java只包含Lib Module中的订阅者索引。


  1. 构建者模式

构建者模式(Builder Pattern) 可以说是开源库的标配了,EventBus 也不例外。你可以使用EventBusBuilder来构建 EventBus 实例,也可以直接调用EventBus.getDefault()获得默认的 EventBus 实例;

  • 1、异常处理配置
配置项 描述 默认值
logSubscriberExceptions 订阅函数执行有异常时,打印异常信息 true
sendSubscriberExceptionEvent 订阅函数执行有异常时,发布SubscriberExceptionEvent事件 true
throwSubscriberException 订阅函数执行有异常时,抛出SubscriberException false
logNoSubscriberMessages 事件无匹配订阅函数时,打印信息 true
sendNoSubscriberEvent 事件无匹配订阅函数时,发布NoSubscriberEvent true
  • 2、索引配置
配置项 描述 默认值
ignoreGeneratedIndex 忽略订阅者索引 false
addIndex(SubscriberInfoIndex index) 添加订阅者索引 无
  • 3、事件订阅配置
配置项 描述 默认值
eventInheritance 是否触发父类事件订阅函数 true
executorService(ExecutorService executorService) 线程池 Executors#newCachedThreadPool()
strictMethodVerification 是否严格验证订阅函数签名 true
skipMethodVerificationFor(Class<?> clazz) 跳过方法签名验证 无

  1. 混淆

ProGuard 和它的继承者 R8 都提供了压缩、优化、混淆和预校验四大功能。压缩和优化会移除未使用的类/方法/字段,混淆会使用无意义的简短名称重命名类/方法/字段。

@Subscribe 订阅方法是通过反射调用的,在编译时没有直接调用,如果不增加反混淆规则的话,在运行时会出现找不到方法名的情况。因此,EventBus需要配置以下混淆规则:

1
2
3
4
5
6
kotlin复制代码-keepattributes *Annotation*
// keep住所有被Subscribe注解标注的方法
-keepclassmembers class * {
@org.greenrobot.eventbus.Subscribe <methods>;
}
-keep enum org.greenrobot.eventbus.ThreadMode { *; }

如果使用了AsyncExecutor,还需要配置混淆规则:

1
2
3
scala复制代码-keepclassmembers class * extends org.greenrobot.eventbus.util.ThrowableFailureEvent {
<init>(java.lang.Throwable);
}

更多内容:《Android | 代码混淆到底做了什么?》


  1. 总结

  • EventBus 是一套 Android / Java 事件订阅 / 发布框架,用于在组件 / 线程间通信的场景中将数据或事件传递给订阅者,EventBus 的特点是可以实现事件订阅者与发布者解耦,以及透明地线程切换;
  • EventBus 3.x 中,订阅者需要使用 @Subscribe 注解修饰订阅方法,可选五种线程模式(POSTING、MAIN、MAIN_ORDERED、BACKGROUND 和 ASYNC);
  • 编译时索引的原理是通过编译时注解处理器生成索引表,记录事件 —— 订阅关系的映射,在运行时直接加载索引表。如果不使用编译时索引,在注册订阅者时就需要递归反射查找类本身与父类中使用@Subscribe注解修饰的方法,影响性能;
  • @Subscribe 订阅方法是通过反射调用的,在编译时没有直接调用,如果不增加反混淆规则的话,在运行时会出现找不到方法名的情况。

推荐阅读

Android 开源库系列完整目录如下(2023/07/12 更新):

  • #1 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(上)
  • #2 初代 K-V 存储框架 SharedPreferences,旧时代的余晖?(下)
  • #3 IO 框架 Okio 的实现原理,到底哪里 OK?
  • #4 IO 框架 Okio 的实现原理,如何检测超时?
  • #5 序列化框架 Gson 原理分析,可以优化吗?
  • #6 适可而止!看 Glide 如何把生命周期安排得明明白白
  • #7 为什么各大厂自研的内存泄漏检测框架都要参考 LeakCanary?因为它是真强啊!
  • #8 内存缓存框架 LruCache 的实现原理,手写试试?
  • #9 这是一份详细的 EventBus 使用教程

⭐️ 永远相信美好的事情即将发生,欢迎加入小彭的 Android 交流社群~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Mybatis collection 一对多查询 Mybat

发表于 2021-02-18

Mybatis collection

需求

1
2
3
4
5
复制代码查询所有用户信息及用户关联的订单信息。

主信息:用户信息

从信息:订单信息

分析

在一对多关联查询时,只能使用resultMap进行结果映射。
1、一对多关联查询时,sql查询结果有多条,而映射对象是一个。
2、resultType完成结果映射的方式的一条记录映射一个对象。
3、resultMap完成结果映射的方式是以[主信息]为主对象,[从信息]映射为集合或者对象,然后封装到主对象中。

po类

PO 用户订单列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
arduino复制代码@Data
public class UserOrderList {


/**
* 用户ID
*/
private int id;

/**
* 用户名称
*/
private String username;

/**
* 生日
*/
private Date birthday;

/**
* 性别
*/
private String sex;

/**
* 地址
*/
private String address;

/**
* 创建时间
*/
private Long createTime;

/**
* 订单列表
*/
private List<Order> orders;
}

注:PO类不能使用extends赋值不进去

UserMapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public interface UserMapper {

/**
* 获取用户订单列表
* @param userId string 用户ID
* @return
*/
UserOrderList getUserOrderList(int userId);

/**
* 获取用户订单列表
* @param userId string 用户ID
* @return
*/
UserOrderList getOrderListByUserId(int userId);

}

UserMapper.xml

分布查询

resultMap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
xml复制代码<!-- 一对多 分布查询 -->
<resultMap id="selectOrderByUser" type="com.xxx.www.mybatis.phase04.po.UserOrderList">
<!-- 用户信息映射 -->
<!-- column 数据库字段 | property 映射po类的字段名 -->
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="birthday" property="birthday"/>
<result column="sex" property="sex"/>
<result column="address" property="address"/>
<result column="create_time" property="createTime"/>

<!-- 一对多 订单关联属性映射:使用select引用方式 -->
<!-- collection定义关联集合类型的属性的封装规则 -->
<!-- property: 对应po类关联的集合字段 orders -->
<!-- column: 关联字段 与sql查询结果的字段对应 -->
<!-- ofType: 指定的是映射到list集合属性中的pojo类型 -->
<!-- fetchType: 加载类型:lazy(延迟加载)eager(立即加载)如果使用,它将取代全局配置参数lazyLoadingEnable -->
<!-- select 分布查询引用 引用的方法 -->
<collection property="orders" column="id" ofType="com.xxx.www.mybatis.phase04.po.Order" select="com.xxx.www.mybatis.phase04.mapper.OrderMapper.selectOderByUserId" fetchType="lazy">
</collection>

</resultMap>
查询sql
1
2
3
4
5
6
7
8
9
10
11
xml复制代码 <!-- 查询用户订单列表 一对多 -->
<select id="getUserOrderList" resultMap="selectOrderByUser" parameterType="int">
SELECT
id, username, birthday, sex, address, create_time
FROM
`user` u
<if test="userId != null ">
where
u.id = #{userId}
</if>
</select>

连表单词查询

resultMap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
xml复制代码<!-- 一对多 链表查询 -->
<resultMap id="selectOrderListByUserId" type="com.xxx.www.mybatis.phase04.po.UserOrderList">
<!-- 用户信息映射 -->
<id column="user_id" property="id"/>
<result column="username" property="username"/>
<result column="birthday" property="birthday"/>
<result column="sex" property="sex"/>
<result column="address" property="address"/>
<!-- 一对多 订单信息映射 -->
<collection property="orders" ofType="com.xxx.www.mybatis.phase04.po.Order">
<id column="order_id" property="orderId"/>
<result column="order_number" property="orderNumber"/>
<result column="user_id" property="userId"/>
<result column="prepayment_amount" property="prepaymentAmount"/>
<result column="actually_paid_amount" property="actuallyPaidAmount"/>
<result column="create_time" property="createTime"/>
<result column="goods_kind" property="goodsKind"/>
<result column="goods_quantity" property="goodsQuantity"/>
<result column="payment_method" property="paymentMethod"/>
<result column="trade_on" property="tradeOn"/>
<result column="price_after_discount" property="priceAfterDiscount"/>
</collection>
</resultMap>
查询sql
1
2
3
4
5
6
7
8
9
10
11
12
13
xml复制代码<!-- 查询用户订单列表 一对多 -->
<select id="getOrderListByUserId" resultMap="selectOrderListByUserId">
SELECT
u.id AS user_id, u.username, u.birthday, u.sex, u.address, o.id AS order_id, o.user_id, u.create_time, o.order_number, o.create_time, o.prepayment_amount, o.actually_paid_amount, o.goods_kind, o.goods_quantity, o.payment_method, o.trade_on, o.price_after_discount
FROM
`user` AS u
JOIN
`order` AS o
where
u.id = o.user_id
and
u.id = #{userId}
</select>

注: 如果两表联查,主表和明细表的主键都是id的话,明细表的多条只能查询出来第一条。
u.id AS user_id 和 o.id AS order_id,直接都用 id 查询多条,得到结果为一条

测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
scss复制代码public class Phase04Test {

/**
* mybatis mysql连接器
*/
private SqlSessionFactory sqlSessionFactory;

private SqlSession sqlSession;

@Before
public void init() throws Exception {
// 加载全局配置文件(同时把映射文件也加载了)
String resource = "phase04/SqlMapConfig.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
// sqlsessionFactory需要通过sqlsessionFactoryBuilder读取全局配置文件信息之后
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

//创建UserMapper对象
sqlSession = sqlSessionFactory.openSession();
}

@Test
public void testFindOrderById() {
// 获取代理对象
OrderMapper mapper = sqlSession.getMapper(OrderMapper.class);
List<OrderExt> orderExtList = mapper.selectOrderAndUserInfo();
System.out.println(orderExtList);
}

@Test
public void getOrderByUserId() {
// 获取代理对象
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
UserOrderList orderList = mapper.getUserOrderList(1);
System.out.println(orderList);
for (Order order: orderList.getOrders()){
System.out.println(order.toString());
}
}

@Test
public void getOrderListByUserId() {
// 获取代理对象
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
UserOrderList orderList = mapper.getOrderListByUserId(1);
System.out.println(orderList);
for (Order order: orderList.getOrders()){
System.out.println(order.toString());
}
}

@Test
public void insertOrder() {
// 获取代理对象
OrderMapper mapper = sqlSession.getMapper(OrderMapper.class);

Order order = insertOrderInfo();
int rows = mapper.insertOrder(order);
sqlSession.commit();
System.out.println(rows);
System.out.println(order.getOrderNumber());
}

/**
* 添加用户信息
*
* @return User
*/
private Order insertOrderInfo() {
Order order = new Order();
// 订单号
String orderNumber = OrderNumberFactory.getOrderCode(1L);
order.setOrderNumber(orderNumber);
// 用户ID
order.setUserId(1);
// 预付金额
order.setPrepaymentAmount(10L);
// 实付金额
order.setActuallyPaidAmount(10L);
// 创建时间
order.setCreateTime(System.currentTimeMillis());
// 商品种类
order.setGoodsKind(1);
// 商品数量
order.setGoodsQuantity(1);
// 支付方式
order.setPaymentMethod("微信");
// 交易流水号
order.setTradeOn("12311");
// 优惠后价格
order.setPriceAfterDiscount(10L);
return order;
}

@After
public void closeSession(){
if (sqlSession != null){
sqlSession.close();
}
}

}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

2021 年,中台的“瓜”你还吃吗?

发表于 2021-02-18

2021年,中台的“瓜”你还吃吗?

企业架构目前还是个小圈子,尽管圈子中大企业不少。虽然小,但是很热闹,如同任何一个技术领域一样,为了加深理解而进行各种各样的 PK。2021 年不过一个月,从中台、数字化到低代码,像翻书一样的翻脸。好在技术圈的“瓜”成熟度一般都不高,不至于炸裂。最近在忙着适应新工作,也在忙着写各类规制化的文章,随心所欲的一直没怎么写,今天对着关于“中台”的争论小感慨下。

一、认知曲线饶过谁?

对方法论的争论不仅是方法论自身的需要,也是人的认知过程的局限,比如,我们经常聊的“Gartner 曲线”,它还有另外一个名字——邓宁-克鲁格效应曲线,就是反映人的认知过程的,长成“Gartner 曲线”那样,应该算是该曲线的变体。

该曲线可以演绎为下图:

新方法出来的时候,一般会有些“光环”,比如“中台”,阿里巴巴做成了,我出来告诉你我做成了,你信不信?当然信啊,人证物证俱在。一着急,难免觉得企业架构这破东西终于有个搞明白的了,不是“银弹”也胜似“银弹”,不服?把你家的骡子还是马拉出来溜溜啊!

不管别人家的到底是骡子还是马,反正自己家拉车的总觉得不成样子,那就把“中台”搬过来吧。结果发现,这东西不比过去任何一种企业架构更简单,不是一把手工程,不把自己家那点儿事搞明白,不花上差不多的时间,没法搞定。都差不多,还未必能吃到心心念念的流量,那不上当了,谁让我做的,先把 CTO 拉去祭天!

当然,“怒不可遏”的终归也是还会“遏”住,冷静点儿想想,大家都是在解决企业内部的 IT 系统到底该怎么做合适的问题,搞来搞去,手段无非“分”、“合”,“分久必合,合久必分”,想想,这逻辑从古人总结出来开始就管用,古人很聪明。“分”与“合”的依据,最终还是业务上的偏好、技术上的实现与成本的可覆盖之间的平衡。乐意付治理空气的成本,就喜欢修烟囱,一般情况下,别人管不到,只有国家“绿水青山就是金山银山”的政策才能管用。

所以,阿里到底拆不拆“中台”,是它自己的事情,业务上急,成本可覆盖,重复了就重复了,以后要削减维护成本了,再合,或者业务不重要了,系统直接扔,数据回收,就当做了一个“笨重”的扩容,谁说聪明人就不会用“笨”招,不是常说“比特币”就是聪明的“笨”吗?

冷静冷静都能走上“开悟之坡”,只要你还记得到底做系统是为了什么就行。不是很多企业也都做成了“中台”吗?只要知道“中台”是为了啥,就能做成。毕竟,从企业架构的视角看过去,企业内部的系统本来也是应该逐渐长成那个模样,对于企业架构而言,那不过是现在为数不多的几种表达方式之一,而且,在我看来,这些表达方式很可能最终是等效的,只要你真的是从企业想要的东西出发去做企业架构。

二、最终还要看“初心”

企业想要的是什么?达成目标。为了达成目标,企业要像一个整体,信息要分享,执行要更有效率,哪怕是盘散沙,也得散在一个盘子里,别满屋子都是。从这个角度出发,你只要不成天别出心裁的往屋里各个角落乱扔沙子,记得在一个盘子里放,你做出来的企业架构就不至于太离谱。而再有点儿自律精神,给差不多大小、形状的沙子聚聚堆儿,不管是叫“中台”还是叫业务组件,还是你喜欢的熊大、熊二,这盘沙子可能就端得上台面了。不喜欢了,就换个维度把沙子再聚聚堆儿,或者大堆儿拆小堆儿,你可能就从一个架构搞到另一个去了。

当然,认真做企业架构比在盘子里搞沙子要复杂和严谨的多,都是付出了很大辛苦且怀揣梦想的,所以即便不赞成,也不至于把“中台”推到反创新、反人性的程度,“中台”自己还没搞到哲学高度呢,这么做属实拔苗助长了。而且,不能把执行的问题等同于方法本身的问题,“军令如山倒”,但是执行的时候也不是一点儿灵活性没有,军事计划从执行开始就在随时修正,这才不至于出问题。僵硬地执行计划,不按照战场环境调整,那不是把失败的责任向计划身上推吗?打仗要做军事计划这个方法没错,《孙子兵法》也极力赞成各种算计,错的是执行时的态度。

做“中台”不算错,错的是做的时候眼里只有“中台”,就像做企业架构也没错,大小企业都可以做,错的是做的时候眼里只有“架构”而没有“企业”。麻雀虽小,五脏俱全,但它确实装不不下一颗“牛心”,“心”太“牛”了也不行,只要切合实际,就是好“心”。

人的认知确实是一个很神奇的东西,它的过程谁也摆脱不掉。为了提升认知速度,人们总是打各种比方,比如我上边讲的这些,但是每种比方都有局限,都难以完整说明事物,甚至容易导致误解。“中台”有没有一点这个呢?可能也有吧,比如,这个“中”和这个“台”不都谈了好久,放在哪里是“中”?这个“台”是“平台”吗?此外,各种文章里常有的大陆军、无人机、海豹小队、航空母舰等等,如果想搞懂这些比方,我觉得,只有深入到理论本身,比如,把这 30 年的企业架构理论至少浏览一遍,不然,这些比方中的任何一个都会给你带到某个沟里去。

另外,至少有过一个深入点儿的实践,再去理解这些东西,不然,有点像是听相声,一阵情绪的波动之后,如果不是想当段子手,估计未必记得住多少。

三、小问题,大缺陷

“中台”也确实有个小问题,但这个小问题真的是个大缺陷,就是方法论上欠缺严谨性,所以,骂它很容易,“中台”几乎不设防;帮它就不太容易,因为找不着关键点,“能力复用”这个点是“通货”,基本上每个敢出来溜的方法论都有这个本事,这顶多算“马步”不算“铁布衫”。

对方法论研究和锻造欠缺一点儿发自内心的关注,对于很多做实现的人来讲,会觉得是个小问题,毕竟,在技术圈里牛的是“一言不合就晒代码”,虽然现在到企业层面开始晒架构了,但是晒方法论的还是少,大家晒的严格来说是“结果”,这些“结果”有可能是在我家“牛”,到你家未必好用,想用的话,要不把你“家”照我“家”改改吧。

其实经过这两年的跌宕起伏,大家也都明白了,好“中台”不是别人家的“中台”,是用别人家的思路、经验建起来的自己家的“中台”,最有帮助的就是这个思路、经验,而系统阐述、提炼思路和经验的,就是方法论,实践做的好的企业,再辛苦辛苦,把方法论也好好做做吧,这样才好普惠天下。

作者简介:

付晓岩,IBM 副合伙人,全球企业咨询服务部大中华区金融核心锐变团队业务发展和交付总监,机械工业出版社《银行数字化转型》和《企业级业务架构设计:方法论与实践》作者。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

阿里面试这样问:redis 为什么把简单的字符串设计成 SD

发表于 2021-02-18

2021开工第一天,就有小伙伴私信我,还给我分享了一道他面阿里的redis题(这家伙绝比已经拿到年终奖了),我看了以后觉得挺有意思,题目很简单,是那种典型的似懂非懂,常常容易被大家忽略的问题。这里整理出来分享一下,顺便自己巩固一下基础,希望对正在面试和想要面试的兄弟有点帮助。

题目大致是这样的

面试官:了解redis的String数据结构底层实现嘛?

铁子:当然知道,是基于SDS实现的

面试官:redis是用C语言开发的,那为啥不直接用C的字符串,还单独设计SDS这样的结构呢?

铁子:·····

“
其实看得出面试官是想看看,铁子是只停留在redis的使用层面,还是对底层数据结构有过更深入的研究,面试嘛都爱这样问大家都懂得。

我们知道redis是用C写的,但它却没有完全直接使用C的字符串,而是自己又重新构建了一个叫简单动态字符串SDS(simple dynamic string)的抽象类型。

redis也支持使用C语言的传统字符串,只不过会用在一些不需要对字符串修改的地方,比如静态的字符输出。

而我们开发中使用redis,往往会经常性的修改字符串的值,这个时候就会用SDS来表示字符串的值了。有一点值得注意:在redis数据库中,key-value键值对含有字符串值的,都是由SDS来实现的。

比如:在redis执行一个最简单的set命令,这时redis会新建一个键值对。

1
复制代码127.0.0.1:6379> set xiaofu "程序员内点事"

此时键值对的key和value都是一个字符串对象,而对象的底层实现分别是两个保存着字符串xiaofu和程序员内点事的SDS结构。

再比如:我向一个列表中压入数据,redis 又会新建一个键值对。

1
复制代码127.0.0.1:6379> lpush xiaofu "程序员内点事" "程序员小富"

这时候键值对的键和上边一样,还是一个由SDS实现的字符串对象,键值对的值是一个包含两个字符串对象的列表对象了,而这两个对象的底层也是由SDS实现。

SDS结构

一个SDS值的数据结构,主要由len、free、buf[]这三个属性组成。

1
2
3
4
5
6
7
8
复制代码struct sdshdr{

  int free; // buf[]数组未使用字节的数量

  int len; // buf[]数组所保存的字符串的长度

  char buf[]; // 保存字符串的数组
}

其中buf[]为实际保存字符串的char类型数组;free表示buf[]数组未使用字节的数量;len表示buf[]数组所保存的字符串的长度。


例如上图表示的是buf[]保存长度为6个字节的字符串,未使用的字节数free为0,但是眼尖的同学会发现这明明是7个字符,还有一个"\0"啊?

上边提到过SDS没有完全直接使用C的字符串,还是沿用了一些C特性的,比如遵循C的字符串以空格符结尾的规则,这样还可以使用一部分C字符串的函数。而对于SDS来说,空字符串占用的一字节是不计算在len属性里的,会为他分配额外的空间。

简单了解SDS结构后,下边我们来看看SDS相比于C字符串有哪些优点。

效率高

举个例子:工作中使用redis,经常会通过STRLEN命令得到一个字符串的长度,在SDS结构中len属性记录了字符串的长度,所以我们获取一个字符串长度直接取len的值,复杂度是O(1)。


而如果用C字符串,在获取一个字符串长度时,需对整个字符串进行遍历,直至遍历到空格符结束(C中遇到空格符代表一个完整字符串),此时的复杂度是O(N)。

在高并发场景下频繁遍历字符串,获取字符串的长度很有可能成为redis的性能瓶颈,所以SDS性能更好一些。

数据溢出

上边提到C字符串是不记录自身长度的,相邻的两个字符串存储的方式可能如下图,为字符串分配了合适的内存空间。


如果此时我想把“程序员内点事”改成“程序员内点事123”,可之前分配的内存只有6个字节,修改后的字符串需要9个字节才能放下啊,怎么搞?


没办法只能侵占相邻字符串的空间,自身数据溢出导致其他字符串的内容被修改。

而SDS很好的规避了这点,当我们需要修改数据时,首先会检查当前SDS空间len是否满足,不满足则自动扩容空间至修改所需的大小,然后再执行修改,如下图所示。


不过有个特殊的地方,在把“程序员内点事”的6个字节扩容到“程序员内点事123”9个字节后,发现free属性的值变成了扩容后字符串的总长度,这就涉及到下边要说的内存重分配策略了。

内存重分配策略

C字符串长度是一定的,所以每次在增长或者缩短字符串时,都要做内存的重分配,而内存重分配算法通常又是一个比较耗时的操作,如果程序不经常修改字符串还是可以接受的。

但很不幸,redis作为一个数据库,数据肯定会被频繁修改,如果每次修改都要执行一次内存重分配,那么就会严重影响性能。

SDS通过两种内存重分配策略,很好的解决了字符串在增长和缩短时的内存分配问题。

1.空间预分配

空间预分配策略用于优化SDS字符串增长操作,当修改字符串并需对SDS的空间进行扩展时,不仅会为SDS分配修改所必要的空间,还会为SDS分配额外的未使用空间free,下次再修改就先检查未使用空间free是否满足,满足则不用在扩展空间。

通过空间预分配策略,redis可以有效的减少字符串连续增长操作,所产生的内存重分配次数。


额外分配未使用空间free的规则:

  • 如果对 SDS 字符串修改后,len 值小于 1M,那么此时额外分配未使用空间 free 的大小与len相等。
  • 如果对 SDS 字符串修改后,len 值大于等于 1M,那么此时额外分配未使用空间 free 的大小为1M。

2.惰性空间释放

惰性空间释放策略则用于优化SDS字符串缩短操作,当缩短SDS字符串后,并不会立即执行内存重分配来回收多余的空间,而是用free属性将这些空间记录下来,如果后续有增长操作,则可直接使用。

数据格式多样性

C字符串中的字符必须符合某些特定的编码格式,而且上边我们也提到,C字符串以\0空字符结尾标识一个字符串结束,所以字符串里边是不能包含\0的,不然就会被误认是多个。

由于这种限制,使得C字符串只能保存文本数据,像音视频、图片等二进制格式的数据是无法存储的。

redis 会以处理二进制的方式操作Buf数组中的数据,所以对存入其中的数据做任何的限制、过滤,只要存进来什么样,取出来还是什么样。

总结

上边只是 redis 数据结构的一点基础知识,没什么难度,但以我的面试经验,如果被问这类问题,不要只含糊其辞的说出底层是SDS,有理有据的把为什么这样实现也说出来。

一来可以显得自己基本功扎实,如果表达的在条理清晰,是个很不错的加分项;在一个主动打消面试官问下去的念头,当然就怕不按套路出牌的人!


“
整理了几百本各类技术电子书,有需要的同学公号[ 程序员内点事 ]内回复[ 666 ]自取。技术群快满了,想进的同学可以加我好友,和大佬们一起吹吹技术。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…719720721…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%