0%

spider

爬虫架构

技术的本质就是结构与组合。今天在谈技术架构,有时候我们也会谈产品架构,再往前走,我们会谈商业架构,它中间都是一个结构的问题。

爬虫开发流程

在聊爬虫架构的时候,非常有必要将爬虫的流程梳理清楚。毕竟万变不离其宗,对于爬虫流程的掌握可以更好的理解、设计、组合爬虫, 爬虫基本步骤如下

  1. 网络包捕获,确定URL
  2. 模拟发送网络请求,获取响应
  3. 解析响应, 获取数据
  4. 解析数据,数据持久化

分布式爬虫架构

均衡分布式

对等分布式

注意

不要过度优化、不要过度预留扩展点、不要过度设计

go

Process And Thread

​ 操作系统会为应用程序创建一个进程, 作为应用程序. 它是一个为应用程序所有资源而运行的容器, 这些资源包含内存地址, 文件句柄, 设备和线程. 每个进程中都包含了一个主进程

线程是操作系统调度的一种执行路径, 用于在处理器中执行我们编写的代码.

一个进程从一个线程开始, 即主线程, 当该线程终止时,进程终止。这是因为主线程是应用程序的原点。然后,主线程可以依次启动更多的线程,而这些线程可以启动更多的线程。

无论线程属于哪个进程,操作系统都会安排线程在可用处理器上运行。每个操作系统都有自己的算法来做出这些决定。

Goroutines and Parallelism

Go 语言层面支持的 go 关键字,可以快速的让一个函数创建为 goroutine,我们可以认为 main 函数就是作为 goroutine 执行的。操作系统调度线程在可用处理器上运行,Go运行时调度 goroutine 在绑定到单个操作系统线程的逻辑处理器中运行(P)。即使使用这个单一的逻辑处理器和操作系统线程,也可以调度数十万 goroutine 以惊人的效率和性能并发运行。

Concurrency is not Parallelism.

​ 并发不是并行。并行是指两个或多个线程同时在不同的处理器执行代码。如果将运行时配置为使用多个逻辑处理器,则调度程序将在这些逻辑处理器之间分配 goroutine,这将导致 goroutine 在不同的操作系统线程上运行。但是,要获得真正的并行性,您需要在具有多个物理处理器的计算机上运行程序。否则,goroutine 将针对单个物理处理器并发运行,即使 Go 运行时使用多个逻辑处理器。

Keep yourself busy or do the work yourself

Leave concurrency to the caller

Never start a goroutine without knowning when it will stop

Any time you start a Goroutine you must ask yourself:

  • When will it terminate?

  • What could prevent it from terminating?

小结: 由开发者管理goroutine的生命周期, 将并发性交给调用方

go

在了解go异常处理的时候,有必要先了解为什么需要做异常处理,异常处理主要在哪几个方面,区分异常和错误的区别等等.

QA

为什么需要做异常处理?

我个人认为有一下几点

  1. 从程序设计的角度来看, 保证程序的鲁棒性,健壮性
  2. 从开发的角度来看, 快速定位问题,解决问题,预防问题

异常处理主要在哪几个方面

异常处理主要在实践上可以区分为

  • 业务层面: 保证业务的稳定性, 逻辑性
  • 基础库: 保证代码逻辑正常

异常与错误的区别

编程语言中的异常和错误是两个相似但不相同的概念。异常和错误都可以引起程序执行错误而退出,他们属于程序没有考虑到的例外情况(exception)。

便于理解举个例子:

一个网络请求, 没有网络-错误

一个网络请求过程中,对方服务器处理超时(注意是对方服务器正常) - 异常

Error 和 Exception

go error

go error 就是一个普通的接口, 普通的值.

1
2
3
4
// https://golang.org/pkg/builtin/#error
type error interface {
Error ()
}

经常使用 errors.New() 来返回一个 error 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// https://go.dev/src/errors/errors.go

package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

New() 返回的是 errorString对象的指针

为什么返回的是指针?

  • 避免创建的error的值一致

基础库中大量定义的error

1
2
3
4
5
6
7
8
// https://go.dev/src/bufio/bufio.go

var (
ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
ErrBufferFull = errors.New("bufio: buffer full")
ErrNegativeCount = errors.New("bufio: negative count")
)

tip: 在定义错误的时候带上包名,便于区分. 如ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte") 中的bufio:

Error VS Exception

各语言的演进历史

C: 但返回值, 入参通过传递指针作为入参, 返回int 表示成功还是失败, 以及如果失败的状态码是什么

C++: 引入了Exception,但无法知道被调用者抛出什么异常

Java: 引入了checked exception,方法的所有者必须申明, 调用者必须处理.

go: 支持多参数返回, 所以很容易在函数签名上实现了error interface的对象,交由调用者处理

如果一个函数返回了 (value,error), 不能对这个value做任何假设, 必须先判定error

补充: go中panic机制,意味着 fatal all, 不能假设调用者来解决panic 意味着代码down了

记录单一清晰的错误, 并处理!!!

注意二值性

go特征

  • 简单
  • 考虑失败而不是成功
  • 没有隐藏的控制流
  • 完全交给开发者来处理
  • Error are values

    对于真正的错误, 表示不可恢复的程序错误,例如索引越界, 不可恢复的环境问题, 堆栈溢出,才使用panic ,对于其他的错误情况,应该是情况我使用error来进行判定

go error type

Sentinel Error

预定义的特定错误,称之为 Sentinel Error. 这个名字起源于计算机编程中使用一个表示不可能进一步处理的做法. 使用特定值来表示错误.

1
2
if err == ErrorSomething { ....}
// 类似于 io.EOF 更底层的syscall.ENOENT

使用 Sentinel Error 值是最不灵活的错误处理策略, 因为调用方法 必须使用==

将结果与预先声明的值进行比较. 当需要提供更多的上下文时,就会出现一个因为反返回一个不同的错误将被破坏相等性检查.

例如一些有意义的fmt.Errorf 携带一些上下文,也会破坏调用者的== ,调用者将被迫查看error.Error()方法的输出,以查看它是否与特定的字符串匹配

tips:

  • 不依赖检查error.Error的输出.

不应该以来检测error.Error的输出, Error方法存在于error接口主要用于方便开发者使用,而不是程序(编写测试会依赖这个返回). 这个输出的字符串用于记录日志,输出到stdout

  • Sentient errors 成为你API公共部分

如果公共函数或方法返回一个特定的值,那么该值必须是公共的,当然要有文档记录,这会增加API的表面积

如果API定义了以恶搞返回特定错误的Interface ,则该接口的所有实现都将被限制为仅返回该错误, 即使他们可以提供更具有描述性错误

比如: io.Reader. 像io.Copy这类函数需要reader的实现者比如返回 io.EOF 来告诉调用者没有更多数据量,但这又不是错误

  • Sentient errors 在这个两个包之间创建依赖

Sentinel errors 最糟糕的问题是他们在两个包之间创建了源码依赖关系

例如检查错误是否等于io.EOF, 代码就必须要导入io包, 虽然听起来似乎不那么糟糕,但想象一下,当项目中的许多包到处错误值时,存在耦合,项目中的其他包必须要导入这些错误值才能校验特定的错误条件

建议:尽可能的避免使用 sentinel errors

Error Types

Error type 实现了error接口自定义类型.例如ExampleError 类型记录了文件和行号以及展示发生了什么. 如下代码所示

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
import (
"fmt"
)

type ExampleError struct {
Msg string
FileName string
Line int
}

func (e *ExampleError) Error() string {
return fmt.Sprintf(`%s:%d %s`, e.FileName, e.Line, e.Msg)
}

func test() error {
return &ExampleError{`something happened`, `example.go`, 33}
}

func main() {
err := test()
switch err := err.(type) {
case nil:
// call succeeded, nothing to do
case *ExampleError:
fmt.Println(`Error occurred on call:`, err)
default:
// unknown error
}
}

与错误值相比, 错误类型的优点是他们能够包装底层错误以提供更多上下文.

官方实例 os.PathError:

1
2
3
4
5
type PathError struct {
Op string
Path string
Err int
}

调用者要使用类型断言和switch,就要让自定义的error变为公共的, 这种模型会导致和调用者产生强耦合,从而导致API非常脆弱

结论: 尽量避免使用error types,或者说尽量避免其成为公共API的一部分

虽然错误类型比sentinel error更完善,提供更多的上下文信息, 但error types 共享error value许多相同的问题.

Opaque errors

不透明的错误处理

直接返回错误而不假设其内容

  • Assert errors for behaviour, not type

在某些情况下,这种二分错误处理方法是不够的, 例如与外界交互(网络), 需要调用方法查错误的性质,以确定重试是否合理. 在这种情况下,可以使用断言错误实现了特定的行为.

Handle Error

Indented flow is for errors

缩进流用于错误

1
2
3
4
5
6
7
// 无错误的正常流程代码应为一条直线
f, err := os.Open(filePath)
if err != nil {
// handle error
}

// do stuff

Eliminate error handing by eliminating errors

通过消除错误来消除错误处理

1
2
3
4
5
6
7
8
9
10
11
12
func AuthenticateRequest(r *Requests) error {
err := authenticate(r.user)
if err != nil {
return err
}
return nil
}


func AuthenticateRequest(r *Requests) error {
return authenticate(r.user)
}

io Reader Example

统计 io.Reader 读取内容的行数代码实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func CountLines(r io.Reader) (int, error) {
var (
br = bufio.NewReader(r)
lines int
err error
)

for {
_, err := br.ReadString('\n')
lines++
if err != nil {
break
}
}
if err != io.EOF {
return 0, err
}
return lines, nil
}

改进-使用bufio.scanner

1
2
3
4
5
6
7
8
9
10
func CountLines1(r io.Reader) (int, error) {
var lines int
sc := bufio.NewScanner(r)

for sc.Scan() {
lines++
}

return lines, sc.Err()
}

Http Example

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
type Header struct {
Key, Value string
}

type Status struct {
Code int
Reason string
}

func WriteResponse(w io.Writer, s Status, headers []Header, body io.Reader) error {
_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", s.Code, s.Reason)
if err != nil {
return err
}
for _, h := range headers {
_, err := fmt.Fprintf(w, "%s:%s\r\n", h.Key, h.Value)
if err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "\r\n"); err != nil {
return err
}

_, err = io.Copy(w, body)
return err
}
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
import (
"fmt"
"io"
)

type Header struct {
Key, Value string
}

type Status struct {
Code int
Reason string
}

type errWrite struct {
io.Writer
err error
}

func (e *errWrite) Write(buf []byte) (int, error) {
var n int
if e.err != nil {
return 0, nil
}

n, e.err = e.Writer.Write(buf)
return n, nil

}
func WriteResponse(w io.Writer, s Status, headers []Header, body io.Reader) error {
ew := &errWrite{Writer: w}
fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", s.Code, s.Reason)

for _, h := range headers {
fmt.Fprintf(ew, "%s:%s\r\n", h.Key, h.Value)
}
fmt.Fprint(ew, "\r\n")
io.Copy(ew, body)

return ew.err
}

Wrap errors

you should only handle errors once. Handing an error means inspecting the error value, and make a single decision

日志与错误无关且对调试没有帮助的信息都应视为噪声, 应予以质疑. 记录的原因是应为某些东西失败了,而包含了答案

  • 错误要被日志记录
  • 应用程序处理错误,保证百分百完整性
  • 之后不在报当前错误

pkg-errors

dev-pkg-errors

pkg-errors

  • 在应用代码中,使用pkg/errors中的errors.New 或者 error.Errorf返回错误
  • 如果调用其他包内的函数,通常简单的直接返回
  • 如果与其他库协作, 考虑使用pkg/errors中的errors.New 或者 error.Errorf返回错误保持堆栈信息
  • 直接放回错误, 而不是每个错误产生的地方打日志
  • 在程序的顶部或者是工作的 goroutine顶部(请求入口), 使用%+v保存堆栈详情记录
  • 使用errors.Cause获取root error 在进行sentinel error判定

小结

Packages that are reusable across many projects only return root error values.

选择 wrap error 是只有 applications 可以选择应用的策略。具有最高可重用性的包只能返回根错误值。此机制与 Go 标准库中使用的相同kit 库的 sql.ErrNoRows

If the error is not going to be handled, wrap and return up the call stack.

这是关于函数/方法调用返回的每个错误的基本问题。如果函数/方法不打算处理错误,那么用足够的上下文 wrap errors 并将其返回到调用堆栈中。例如,额外的上下文可以是使用的输入参数或失败的查询语句。确定您记录的上下文是足够多还是太多的一个好方法是检查日志并验证它们在开发期间是否为您工作。

Once an error is handled, it is not allowed to be passed up the call stack any longer.

一旦确定函数/方法将处理错误,错误就不再是错误。如果函数/方法仍然需要发出返回,则它不能返回错误值。它应该只返回零**(比如降级处理中,你返回了降级数据,然后需要 return nil

爬虫

又双叒叕 一篇相关于爬虫对抗的总结. 一般来说对于爬虫的对抗主要体现在如下几个方面

  • 反调: 反抓包\反调试\代码混淆与防护

  • 反(欺诈)模拟: 网络协议校验\TLS\请求头(字段\字段顺序\反盗链)校验

  • 数据防护\验证码\风控对抗等

反爬虫(风险策略)工程师预防以及防止爬虫工程师对数据的抓取, 一方要”守”一方要”攻”,各为其主各司其职你来我往谁也多呈不让.

反抓包

反抓包的目的多种多样,简而言之便是让爬虫工作者无法捕获网络数据包

如: 不走http\不走代理\请求分离\SSLping单(双)向校验\网络协议定制等

反调试

反调试的策略诸多, 主要体现方面有思路为 预防调试\ 阻断调试\ 破坏调试,让爬虫逆向者无法有效进行调试

预防调试

预防调试的手段也多种多样, 简而言之便是预防开发者调试从而阻断分析无法进入下一步,主要体现在环境检测\动态代码

譬如:

  • web端: 控制台检测\动态js\wasm\代码防护与混淆\hook检测\堆栈顺序检测
  • Android: 运行环境检测(root\签名校验\hook检测\模拟器检测)\进程检测与调试工具检检测(jadx\apktool\jeb\xposed\frida\ida)\壳\so\

  • web:

    • 分辨率检测
    • 控制台检测
    • 动态js
    • wasm
  • Android:

    • 运行环境检测
      • root检测
      • 设备检测
      • 签名校验
    • 调试器工具检查
      • 工具特性检测
        • jadx
        • apktool
        • jeb
        • xposed
        • frida
        • ida
    • 代码防护
      • so
      • 代码隐写
  • 代码混淆
    • 布局混淆
    • 数据混淆
    • 数字混淆
    • 字符串混淆
    • 控制流平坦化

阻断调试

阻断调试, 顾名思义便是阻止调试.或着说将你引入错误的逻辑中,无法正常的调试

譬如代码中的环境检测\设备指纹

破坏调试

相对于阻断调试更加极端的手段,一方面对调试者信息进行采集、攻击调试者。例如删除文件、重置电脑、甚至释放病毒等

小结

做反调试主要有两个方面一方面是特征区分预防\阻断\破坏调试, 另一方面是对代码进行保护与混淆

反(欺诈)模拟

如果说反调试是为了防止逆向工作者,那么反模拟便是针对爬虫工程师.

简而言之,防止模拟请求. 那么对于此.我们只需要区分开这个请求, 亦或者将结果数据进行处理这样就可以让开发者无法拿到正常有价值的数据,从而实现反爬虫的效果.

因为我们知道爬虫简单来说就是模拟用户请求的代码实现

反模拟主要在对于爬虫的请求进行甄别,常见的关键点有

请求库特征,譬如tls指纹

请求协议的区分: 譬如强制指定http2或quic等\使用自研的协议

参数校验: 校验的思路有 参数字段的校验如cookie等, 参数顺序的校验

加密与编码算法的定制修改,

  • 位运算\ 在特殊的头\尾部加入字符
  • 魔改hash算法\base(16\32\64)等
  • 对称加密与非对称加密算法

校验的字段通常使用到加密算法或编码算法

数据防护

当我们完成了以上的种种, 便将”战场”迁移到最后的数据保护上,

常见的方式有数据加密(从服务端返回的数据进行加密或编码)

数据隐写

无法轻松直接的从返回的数据包中获取到数据, 只需要保证界面展示上正常的即可

验证码

验证码不必多说, 譬如常见的如下几种类型

  • 滑动型
  • 计算型
  • 点选型(图像: 一维\二维\三维)
  • 短信或语音验证型

通常验证码还会对对应的譬如图片进行安全处理, 如图片重排\模糊化等等干扰识别以及防止图像被还原

还有就是检测运行环境

风控

IP风控

设备风控

账号风控

风控其核心的对抗 个人认为有两点

  1. 特征
  2. 逻辑

写的比较散,但实现起来却并不是唯一, 仅按个人认为的进行的区分, 大多情况都不可能单独出现.

最后

无论是爬虫工程师还是反爬虫亦或者风险策略工程师. 攻与守并非是一成不变的. ,掌握底层逻辑,不断的成长向上吧. 瑞斯拜~

frida

frida 主动调用

主动调用: 强制去调用函数执行

被动调用: 由app主导,按照正常的执行顺序执行函数. 函数执行完全依靠与用户交互完成从而间接的调用到关键函数

在Java中,类的函数可以分为两种: 类函数与实例方法, 也可以称之为静态方法和动态方法.

类函数使用关键字static 修饰,与对应的类绑定, 当然如果该类函数还被public 修饰,则在外部就可以直接通过类去调用

实例方法没有被 staic 修饰,在外部只能通过实例化对应的类,在通过该实例调用对应的方法.

在frida中主动调用的类型会根据方法的类型区分开来, 类函数的直接调用使用Java.use 即可,实例方法则需要先找到对应的实例后对方法进行调用, 通常使用Java.choose.

示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setImmediate(function () {
console.log('Script loaded successfully, start hook...');
Java.perform(function () {
console.log('Inside java perform function...');

// 静态(类)函数 主动调用
let class_name = Java.use('com.xxx.xxx.xxx');
let result1 = class_name.method();

// 动态(实例)方法 主动调用
Java.choose('com.xxx.xxx.xxx', {
onMatch: function (instance) {
console.log('instance found ', instance);
let result2 = instance.method();
},
onComplete: function () {
console.log('search complete');
}
});
});
})

frida-rpc

通过exports 将结果导出,以便于python 结合frida模块直接调用.

js脚本与hook脚本写法基本一致,示例代码如下所示

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
// rpc.js
function func1() {
console.log('Script loaded successfully, start hook...');
var xxx_result = '';
Java.perform(function () {
console.log('Inside java perform function...');
var class_name = Java.use('com.xxx.xxx.xxx');
xxx_result = class_name.method_name('参数');
});
return xxx_result;
};


function func2() {
console.log('Script loaded successfully, start hook...');
var xxx_result = '';
Java.perform(function () {
console.log('Inside java perform function...');
Java.choose('com.xxx.xxx', {
onMatch: function (instance) {
xxx_result = class_name.method_name('参数');
},
onComplete: function () {
console.log('search complete');
}
})
});
return xxx_result;
}

rpc.exports = {
rpc_func1: func1,
rpc_func2: func2
}
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
# rpc.py
# File: proc.py
# User: Payne-Wu
# Date: 2022/6/26 17:33
# Desc:
import sys
import frida
from loguru import logger

device = frida.get_usb_device()
script_path = "HookScript/example.js"


def message_call_back(message, data):
"""
message call back
:param message:
:param data:
:return:
"""
logger.info(message)
logger.info(data)


def attach_hook(app_name):
"""
:param app_name:
:return:
"""
process = device.attach(app_name)
with open(script_path, 'r', encoding='utf-8') as f:
script = process.create_script(f.read())
script.on('message', message_call_back)
script.load()
sys.stdin.read()


def spawn(package_name):
"""
:param package_name:
:return:
"""
pid = device.spawn(package_name)
process = device.attach(pid)
with open(script_path, 'r', encoding='utf-8') as f:
script = process.create_script(f.read())
script.on('message', message_call_back)
script.load()
# rpc
# script.exports.func_name
device.resume(pid)
sys.stdin.read()


if __name__ == '__main__':
spawn('com.xxx.xxx')

smali

Smali

Smali是Android虚拟机的反汇编语言

Android代码一般是用JVM语言编写,执行Androdi程序一般需要用到JVM,在Android平台上也不例外,但是出于性能上的考虑,并没有使用标准的JVM,而是使用专门的Android虚拟机(5.0以下为Dalvik,5.0以上为ART)。Android虚拟机的可执行文件并不是普通的class文件,而是再重新整合打包后生成的dex文件。smali是dex格式的文件的汇编器
反汇编器\ 其语法是一种宽松的jasmin/dedexer 语法,实现了.dex格式的所有功能(注解/调试信息/线路信息等)

学习smali必要性

  1. 动态调试与修改APK, 当静态分析已经无法满足时,此时便需要对Android进行动态调试, 而动态调试便是调试smail
  2. 修改APK运行逻辑, 通过修改smali代码,在重新打包.便可对app进行持久化的修改.(常用的注入均在外部而不是app内部)

插件: java2smail

Smali基本语法

关键字

关键字

关键字 说明
.class 定义类名
.super 定义父类名
.source 定义源文件名
.filed 定义字段
.method 定义方法开始
.end method 定义方法结束
.annotation 定义注解开始
.end annotation 定义注解结束
.implements 定义接口指令
.local 指定了方法内局部变量的个数
.registers 指定方法内使用寄存器的总数
.prologue 表示方法中代码的开始处
.line 表示java源文件中指定行
.paramter 指定方法的参数
.param 和.paramter含义一致,但是表达格式不同

数据类型

Smali Java 备注
v void 只能用于返回值类型
Z boolean
B byte
S short
C char
I int
J long
F float
D double
Lpackage/name; 对象类型 L表示这是一个对象类型,package表示该对象所在的包,;表示对象名称的结束
[类型 数组 [I表示一个int型数据,[Ljava/lang/String 表示一个String的对象数组

类声明

1
.class + 修饰符 + 类名

构造函数

1
2
3
.method 权限修饰符 constructor <init>(参数类型) V
# 方法体
.end method

成员变量定义格式

1
2
.field public/private [static][final] varName:<类型>
.field 访问权限修饰符 类型修饰符 变量名:类名路径

返回值关键字

返回关键字 Java数据类型
return byte short float char boolean
return-void void
return-wide long double
return-object 数组 object

获取指令

1
iget, sget, iget-boolean, sget-boolean, iget-object, sget-object

操作指令

1
2
iput, sput, iput-boolean, sput-boolean, iput-object, sput-object
array的操作是aget和aput

指令解析

1
2
3
4
sget-object v0,Lcom/aaa;->ID:Ljava/lang/String;
获取ID这个String类型的成员变量并放到v0这个寄存器中
iget-object v0,p0,Lcom/aaa;->view:Lcom/aaa/view;
iget-object比sget-object多一个参数p0,这个参数代表变量所在类的实例。这里p0就是this

example

1
2
3
4
5
6
7
8
9
// example 1 相当于java代码:this.timer = null;
const/4 v3, 0x0
sput-object v3, Lcom/aaa;->timer:Lcom/aaa/timer;

// example 2
.local v0, args:Landroid/os/Message;
const/4 v1, 0x12
iput v1,v0,Landroid/os/Message;->what:I
// 相当于java代码:args.what = 18; 其中args为Message的实例

调用指令

调用关键字 作用
invoke-virtual 非私有实例方法调用
invoke-direct 构造方法以及私有方法的调用
invoke-static 静态方法的调用
invoke-super 父类方法的调用
invoke-interface 接口方法调用

调用格式: invoke-指令类型 {参数1, 参数2,…}, L类名;->方法名 如果不是是静态方法,参数1代表调用该方法的实例。

寄存器

Java中变量都是存放在内存中的,Android为了提高性能,变量都是存放在寄存器中的,寄存器为32位,可以支持任何类型。64位类型(Long/ Double) 用2个格式的寄存器表示; Dalvik字节码有两种类型: 原始类型和引用类型(
包括对象和数组)

寄存器分为如下两类: 1、本地寄存器: 用v开头数字结尾的符号来表示,v0, v1, v2,… 2、参数寄存器: 用p开头数字结尾的符号来表示,p0,p1,p2,…
注意:
在非static方法中,p0代指this,p1为方法的第一个参数。
在static方法中,p0为方法的第一个参数。

1
2
const/4 v0, 0x1 //把值0x1存到v0本地寄存器
iput-boolean v0,p0,Lcom/aaa;->IsRegisterd:Z //把v0中的值赋给com.aaa.IsRegistered,p0代表this,相当于this.Isregistered=true

tip:

查看smali代码时可以和java代码结合来看

referer

Dalvik 字节码

https://www.jianshu.com/p/9931a1e77066

frida

frida启动方式

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
# frida -h
usage: frida [options] target

positional arguments:
args extra arguments and/or target

optional arguments:
-h, --help show this help message and exit
-D ID, --device ID connect to device with the given ID
-U, --usb connect to USB device
-R, --remote connect to remote frida-server
-H HOST, --host HOST connect to remote frida-server on HOST
--certificate CERTIFICATE
speak TLS with HOST, expecting CERTIFICATE
--origin ORIGIN connect to remote server with “Origin” header set to ORIGIN
--token TOKEN authenticate with HOST using TOKEN
--keepalive-interval INTERVAL
set keepalive interval in seconds, or 0 to disable (defaults to -1 to auto-select based on transport)
--p2p establish a peer-to-peer connection with target
--stun-server ADDRESS
set STUN server ADDRESS to use with --p2p
--relay address,username,password,turn-{udp,tcp,tls}
add relay to use with --p2p
-f TARGET, --file TARGET
spawn FILE
-F, --attach-frontmost
attach to frontmost application
-n NAME, --attach-name NAME
attach to NAME
-p PID, --attach-pid PID
attach to PID
-W PATTERN, --await PATTERN
await spawn matching PATTERN
--stdio {inherit,pipe}
stdio behavior when spawning (defaults to “inherit”)
--aux option set aux option when spawning, such as “uid=(int)42” (supported types are: string, bool, int)
--realm {native,emulated}
realm to attach in
--runtime {qjs,v8} script runtime to use
--debug enable the Node.js compatible script debugger
--squelch-crash if enabled, will not dump crash report to console
-O FILE, --options-file FILE
text file containing additional command line options
--version show program's version number and exit
-l SCRIPT, --load SCRIPT
load SCRIPT
-P PARAMETERS_JSON, --parameters PARAMETERS_JSON
parameters as JSON, same as Gadget
-C USER_CMODULE, --cmodule USER_CMODULE
load CMODULE
--toolchain {any,internal,external}
CModule toolchain to use when compiling from source code
-c CODESHARE_URI, --codeshare CODESHARE_URI
load CODESHARE_URI
-e CODE, --eval CODE evaluate CODE
-q quiet mode (no prompt) and quit after -l and -e
-t TIMEOUT, --timeout TIMEOUT
seconds to wait before terminating in quiet mode
--no-pause automatically start main thread after startup
-o LOGFILE, --output LOGFILE
output to log file
--eternalize eternalize the script before exit
--exit-on-error exit with code 1 after encountering any exception in the SCRIPT
--auto-perform wrap entered code with Java.perform
--auto-reload Enable auto reload of provided scripts and c module (on by default, will be required in the future)
--no-auto-reload Disable auto reload of provided scripts and c module

Injection

attach hook: 这种模式建立在app已经启动的情况下,frida利用ptrace的原理注入app进而完成Hook
spawn hook: 将app启动权限交与frida 来控制。使用spawn实现hook时会由frida将app重启在进行hook
注意:由于attach hook 基于ptrace原理进行完成,因此无法在IDA正在调试的目标app以attach注入进程中,当然若先用frida attach注入
在使用IDA进行调试则正常

Shell

attach

1
2
frida -Ul script_hook.js [-n] app_name
frida -Ul script_hook.js -p pid

spawn

1
frida -Ul script_hook.js -f Identifier(package name) --no-pause

Python script

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
import sys
import frida
from loguru import logger
from pptrint import pprint


def message_callback(message, data):
logger.info(f"[*] {message}")


device = frida.get_use_device(-1)

# attach
process = device.attach('className')
script = process.create_script('hook_script')
script.on('message', message_callback)
script.load()
sys.stdin.read()

# spawn
device = frida.get_use_device(-1)
pid = devices.spawn(['packageName'])
process = device.attach(pid)
script = process.create_script('hook_script')
script.on('message', message_callback)
script.load()
sys.stdin.read()

frida api

JavaScript-api : https://frida.re/docs/javascript-api

JavaScript-api-java: https://frida.re/docs/javascript-api/#java

JavaScript-api-module: https://frida.re/docs/javascript-api/#module

Hook

1
2
3
4
Java.perfrom(function () {
console.log('script successfully loaded, start hook...');
// hook script
});

Hook 类方法

1
2
3
4
5
6
7
8
9
Java.perfrom(function () {
console.log('script successfully loaded, start hook...');
// hook class script
let class_name = Java.use('com.xxx.xxx.class_name');
class_name.method.implementation = function () {
// do something
// this.xx
}
});

this.成员变量名.value

Hook 内部(匿名)类方法

1
2
3
4
5
6
7
8
9
10
Java.perfrom(function () {
console.log('script successfully loaded, start hook...');
// hook class script
// 类路径$内部类名 在smail找
let class_name = Java.use('com.xxx.xxx.class_name$xx');
class_name.method.implementation = function () {
// do something
// this.xx
}
});

从匿名类/内部类访问外部类的属性写法: this.this$0.value.外部类的属性名.value

Hook 重载方法

1
2
3
4
5
6
7
8
9
10
11
12
13
Java.perfrom(function () {
console.log('script successfully loaded, start hook...');
// hook class script
// 类路径$内部类名 在smail找
let class_name = Java.use('com.xxx.xxx.class_name');
class_name.method.overload(参数1, 参数2
...).
implementation = function () {
// do something
// this.xx
}
});
// overload(参数1,参数2...) 可以根据报错来确定

Hook 构造方法

1
2
3
4
5
6
7
8
9
10
Java.perfrom(function () {
console.log('script successfully loaded, start hook...');
// hook class script
// 类路径$内部类名 在smail找
let class_name = Java.use('com.xxx.xxx.class_name');
class_name.$init().implementation = function () {
// do something
// this.xx
}
});

Hook 实例

1
2
3
4
Java.perfrom(function () {
console.log('script successfully loaded, start hook...');
let variable = Java.use('com.xxx.xxx.class_name').$new(参数);
});

工作

流程

规范的结构的构造端到端

计算机

《我对计算的理解· 吴瀚清》

技术的本质就是结构与组合

今天我们在谈技术架构,有时候我们也会谈产品架构,再往前走,我们会谈商业架构,它中间都是一个结构的问题。

技术最重要的目标是开始追求生产效率,带来产能上的巨大提升,当产能得到一个巨大提升之后,就能够创造出越来越多的产品,进而实现规模化。技术要通过架构师或者工程师的工作才能完成这个过程。

技术要走向的就是规模化

计算机真正在做的事情是模拟

创造

闭环

在职场中,在领导口中.口口声声说 “闭环”,但 究竟什么是闭环?

闭环思维是一种有始有终的思维,它是一份生活的智慧。

比如拖延症导致的熬夜。由于有拖延症,导致无法及时完成任务,最后不得不熬夜,第二天精神萎靡,工作效率下降,于是不得不继续熬夜。这就是一个恶性闭环。

要想改变这个闭环,我们需要找到一个突破点,对上一个阶段的效果进行总结,把控改进方向。

工作效率就是一个突破点,我们可以提升工作效率,原本需要2个小时完成的任务,火力全开用1个小时做完,那么晚上就不需要再熬夜了。有了充足的睡眠,第二天精力充沛,就可以保证工作效率,于是形成了一个正向的闭环。

什么是闭环思维

闭环思维实际上被称为反馈控制系统,它将系统输出的测量值与预期和定值进行比较,由此造成一个偏差信号,然后运用偏差去做调整,便于输出值尽可能接近期望值。

PDCA循环将管理分为计划(Plan)、执行(Do)、检查(Check)、行动(Act) 四个阶段,这四个阶段不是独立存在的,而是周而复始的循环。这也是我们在企业中经常听到的“商业闭环”“闭环管理” 等概念。

我们这里提到的闭环思维是指在一定的基础上,对于他人发起的活动或者工作,无论我们完成的程度如何,都要在要求的时间之内认真地反馈给发起人,并且每个活动或者工作都要贯穿这个思维。

思考

1
2
3
4
5
6
你需要有体系结构化思维的能力 -- 5w3h\ 6why
你做的事情它有什么独特的优势,价值点在哪里
你是否做出了壁垒,形成核心竞争力
你做的事情差异化在哪里
你的事情是否沉淀了一套可复用的物理资料与方法论
为什么是你来做,其他人是否可以?

理解业务

技术 + 产品 + 运营

Referer

闭环

github

搜项目大全

awesome-xxx

例如想了解python的所有技术体系,我们就可以搜索 awesome-python ,当然Java\spider\datascience等等

搜索样例

xxx simple 或者 xxx demo

空项目结构

xxx starter

例如

springboot starter

springboot+mybatis starter

教程与学习路线

xxx tutorial

例如: python tutorial

当然结合 awesome-xxx 更香哦

常用快捷键

s: 快速定位到搜索框进行搜索

t: 项目文件树

ctrl + K : 快速查看文件

.: 在线预览\运行项目

快捷键: https://docs.github.com/cn/get-started/using-github/keyboard-shortcuts

GitHub Search: https://github.com/search/advanced

source code

本文仅提供思路,还需自身有足够的基础。

很多时候通常需要阅读很多源码,当然阅读源码也是有很多技巧的。

通常,根据他人总结的文档,先看整体(目录和类图)再看局部(类和函数)。

对于 Java 项目,就是先查看包的层级关系,然后分析包中类(接口)之间的关系,包括继承、实现、委托、方法调用等,最后再查看某个类具体的属性和方法的具体实现。

对于Python 项目,也是这样,先整体后局部,后核心。遵循先广度在深度,先核心在周围

源码阅读基本步骤

首先是通读官方文档,对框架或项目有个大致的了解

学习一些基础库及联系紧密的方法

找到一个趁手的IDEA,深层沉浸式阅读

尝试二次开发、修源码进行深入理解

源码阅读小技巧

搜索

根据文件名搜索文件/类等搜索

快捷键:shift + shift(连按两次)

image-20220530172201840

字段搜索

局部搜索快捷键:Win: Ctrl + F Mac: Command + F

全局搜索快捷键:Win: Ctrl + shift + F Mac: Command + Shift + F

image-20220530172449113

代码跳转

对应文件跳转

Mac快捷键:Command + e Win快捷键:Ctrl + e

image-20220530175210390

跳转到上/下次光标的位置

查看源码时,经常需要在两个类中来回跳转,这个功能就变得相当实用!

查看上次光标位置快捷键:Win: Alt + ← Mac: Option + Command + ←

查看下次光标位置快捷键:Win: Alt + → Mac: Option + Command + →

调用

查看方法调用树

可以查看指定方法的所有调用方和被调方。

快捷键:Win: Ctrl + Alt + H MAC: Control + Option + H

image-20220530175508252

调用与被调用

方法函数结构

快捷键:快捷键:Win: Alt + 7 Mac: Command + 7

image-20220530175614503

类关系

image-20220719204419565

image-20220719204515904

referer

https://juejin.cn/post/7012168150493954079#heading-3

Jetbrains

Jetbrains plugins

通用

ignore:生成git、dodkcer配置文件

Node.js:提供nodejs 支持

CSV: 用于编辑 CSV/TSV/PSV 文件的轻量级插件,具有灵活的表格编辑器、语法验证、结构突出显示、可自定义的颜色、新意图和有用的检查。

Rainbow CSV:用于以不同颜色突出显示 CSV 文件的插件。

Rainbow:基于 IntelliJ IDEA 的 IDE 的彩虹高亮标识符和分隔符

Tabnine: 代码智能提示

Ideolog: “.log”文件的交互式查看器。

CodeGlance Pro:提供代码缩略图

env files support:基于 .env、Dockerfile 和 docker-compose.yml 文件的环境变量补全。

BashSupport Pro:是用于高级Bash和shell脚本开发的插件—调试器、测试运行程序、代码完成、查找用法、重命名、ShellCheck、shfmt等。

Key Promoter X:提供快捷键提示

Nyan Progress Bar:彩虹进度条

translation: 翻译插件

Markdown:提供markdown 格式支持

Monokai Pro:主题

Graph Buddy: 旨在加快您阅读和学习源代码的过程。Graph Buddy 插件提供了一组有用的功能和技术,可帮助您轻松浏览扭曲的代码依赖项。同时,它可以让您更好地了解代码库中的代码结构。

Material Theme UI Plugin: 提供主题

Material Design Dark-Theme: 黑暗主题

AEM IDE: 保护您避免引入讨厌的错误,在编写代码时给您反馈

Save Actions:格式化代码插件

Grep console: 自定义控制台输出

statistic:代码统计插件

GitToolBox: git增强

Pycharm

AEM PRO: 本部分提供有关开始使用 IntelliJ AEM 所需步骤的信息,包括插件安装和初始项目设置。

Sourcery 是一个人工智能驱动的编码助手,它可以帮助你更快地编写更好的 Python 代码。它通过动态提供重构建议来工作,您可以立即将这些建议集成到您的代码中。

Idea

Codota: 代码智能提示

Lombok:简化臃肿代码插件

Alibaba Java Coding Guidelines:阿里巴巴代码规范检查插件

CamelCase: 驼峰命名和下划线命名转换

MybatisX:高效操作 MybatisX 插件

SonarLint:代码质量检查插件

checkstyle:代码风格检查插件

MetricsReloaded 代码复杂度检查插件

Jump To Line: 转到任意行并设置执行点而无需执行前面的代码。

Maven helper: 查看、分析和排除相互冲突的依赖项

Squaretest: 自动生成单元测试

Jrebel:热部署插件

EasyCode: 数据库表自动的生成实体类

Arthas Idea: Java 在线诊断工具

源码插件技巧:https://juejin.cn/post/7012168150493954079

go

反射简介

Go在标准库中提供的reflect包让Go程序具备运行时的反射能力(reflection)。反射是程序在运行时访问、检测和修改它本身状态或行为的一种能力,各种编程语言所实现的反射机制各有不同。

Go语言的interface{}类型变量具有析出任意类型变量的类型信息(type)和值信息(value)的能力,Go的反射本质上就是利用interface{}
的这种能力在运行时对任意变量的类型和值信息进行检视甚至是对值进行修改的机制。

反射让静态类型语言Go在运行时具备了某种基于类型信息的动态特性。利用这种特性,fmt.Println在无法提前获知传入参数的真正类型的情况下依旧可以对其进行正确的格式化输出

反射三大法则

Rob Pike还为Go反射的规范使用定义了三大法则,如果经过评估,你必须使用反射才能实现你要的功能特性,那么你在使用反射时需要牢记这三条法则。

  • 反射世界的入口:经由接口(interface{})类型变量值进入反射的世界并获得对应的反射对象(reflect.Value或reflect.Type)。
  • 反射世界的出口:反射对象(reflect.Value)通过化身为一个接口(interface{})类型变量值的形式走出反射世界。
  • 修改反射对象的前提:反射对象对应的reflect.Value必须是可设置的(Settable)。

reflect.TypeOf和reflect.ValueOf是进入反射世界仅有的两扇“大门”。通过reflect.TypeOf这扇“门”进入反射世界,你将得到一个reflect.Type对象,该对象中包含了被反射的Go变量实例的所有类型信息;

而通过reflect.ValueOf这扇“门”进入反射世界,你将得到一个reflect.Value对象。Value对象是反射世界的核心,不仅该对象中包含了被反射的Go变量实例的值信息,而且通过调用该对象的Type方法,我们还可以得到Go变量实例的类型信息,这与通过reflect.TypeOf获得类型信息是等价的:

reflect.Value.Interface()是reflect.ValueOf()
的逆过程,通过Interface方法我们可以将reflect.Value对象恢复成一个interface{}类型的变量值。这个离开反射世界的过程实质是将reflect.Value中的类型信息和值信息重新打包成一个interface{}的内部表示。

小结

reflect包所提供的Go反射能力是一把“双刃剑”,它既可以被用于优雅地解决一类特定的问题,但也会带来逻辑不清晰、性能问题以及难于发现问题和调试等困惑。
因此,我们应谨慎使用这种能力,在做出使用的决定之前,认真评估反射是不是问题的唯一解决方案;在确定要使用反射能力后,也要遵循上述三个反射法则的要求。

go

Go标准库

Go标准库中的unsafe包非常简洁,如下所示

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
// 用于获取一个表达式值的大小
func Sizeof(x ArbitraryType) uintptr

// 用于获取结构体中某字段段地址偏移量(相对于结构体变量的地址)
// Offsetof函数应用面较窄,仅用于结构体某字段的偏移值
func Offsetof(x ArbitraryType) uintptr

// Alignof用于获取一个表达式的内存补齐系数
func Alignof(x ArbitraryType) uintptr

// Add 将 len 添加到 ptr 并返回更新后的指针 Pointer(uintptr(ptr) + uintptr(len))。
// len 参数必须是整数类型或无类型常量。
// 一个常量 len 参数必须可以用一个 int 类型的值来表示;
// 如果它是一个无类型常量,它被赋予 int 类型。 Pointer 的有效使用规则仍然适用。
func Add(ptr Pointer, len IntegerType) Pointer


// 函数 Slice 返回一个切片,其底层数组从 ptr 开始,长度和容量为 len。
// Slice(ptr, len) 等价于 ([len]ArbitraryType)(unsafe.Pointer(ptr))[:] ,
// 除了作为特殊情况,如果 ptr 为 nil 且 len 为零,Slice 返回 nil。
// len 参数必须是整数类型或无类型常量。
// 一个常量 len 参数必须是非负的并且可以用一个int类型的值来表示;
// 如果它是一个无类型常量,它被赋予int类型。在运行时,
// 如果len为负数,或者ptr为nil且len不为零,则会发生运行时恐慌
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

典型使用

怎么理解Go核心团队在尽力保证go类型安全的情况下,又提供了可以打破安全屏障的unsafe.Pointer 这一行为?

首先被广泛应用于Go标准库和Go 运行时的实现当中,reflect、sync、syscall、runtime都是unsafe包的重度用户。

reflect

ValueOf 和TypeOf函数是reflect包中用得最多的两个API,他们是进入运行时反射层、获取发射层信息的入口。这两个函数均将任意类型变量转化为一个interface{} 类型变量,再利用unsafe.Pointer
将这个变量绑定的内存区域重新解释为reflect.emptyInterface类型,以获得传入变量的类型和值类的信息

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
// $GOROOT/src/reflect/values.go
// emptyInterface is the header for an interface{} value.
type emptyInterface struct {
typ *rtype
word unsafe.Pointer
}


// unpackEface converts the empty interface i to a Value.
func unpackEface(i any) Value {
e := (*emptyInterface)(unsafe.Pointer(&i))
// NOTE: don't read e.word until we know whether it is really a pointer or not.
t := e.typ
if t == nil {
return Value{}
}
f := flag(t.Kind())
if ifaceIndir(t) {
f |= flagIndir
}
return Value{t, e.word, f}
}

// $GOROOT/src/reflect/type.go
// If i is a nil interface value, TypeOf returns nil.
func TypeOf(i any) Type {
eface := *(*emptyInterface)(unsafe.Pointer(&i))
return toType(eface.typ)
}

sync

sync.Pool 是个并发安全的高性能临时对象缓冲池。Pool 为每个P分配了一个本地缓冲池,并通过下列函数为每个P分配啦一个本地的缓冲池,并通过如下函数实现快速定位P的本地缓冲池。

1
2
3
4
5
func indexLocal(l unsafe.Pointer, i int) *poolLocal {
lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{}))
return (*poolLocal)(lp)
}
// indexLocal函数的本地缓冲池快速定位时通过结合unsafe.Pointer包与uinptr的指针运算实现

标准库中的saycall包封装了与操作系统交互的系统调用接口,比如Statfs、Listen、Select

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
// $GOROOT/src/syscall/zsyscall_linux_amd64.go
func Statfs(path string, buf *Statfs_t) (err error) {
var _p0 *byte
_p0, err = BytePtrFromString(path)
if err != nil {
return
}
_, _, e1 := Syscall(SYS_STATFS, uintptr(unsafe.Pointer(_p0)), uintptr(unsafe.Pointer(buf)), 0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}

func Listen(s int, n int) (err error) {
_, _, e1 := Syscall(SYS_LISTEN, uintptr(s), uintptr(n), 0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}

func Select(nfd int, r *FdSet, w *FdSet, e *FdSet, timeout *Timeval) (n int, err error) {
r0, _, e1 := Syscall6(SYS_SELECT, uintptr(nfd), uintptr(unsafe.Pointer(r)), uintptr(unsafe.Pointer(w)), uintptr(unsafe.Pointer(e)), uintptr(unsafe.Pointer(timeout)), 0)
n = int(r0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}

这类的高级调用的最终都会落到调用函数下面一系列的Syscall和RawSyscall函数上面。

1
2
3
4
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)

这些Saycall系列的函数接受的参数类型均为uintptr,这样当封装的系统调用的参数为指针类型时(比如上面Select的参数r、w、e等)。只能只能通过unsafe.uintptr值,就像上面Select函数实现中那样。因此,syscall包是unsafe重度使用者,它的实现离不开的unsafe.Pointer。

runtime包中的unsafe包的典型应用

runtime 包实现的goroutine调度和内存管理(包括GC)都有unsafe包的身影以goroutine的栈管理为例:

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
103
// $GOROOT/src/runtime/stack.go
type stack struct {
lo uintptr
hi uintptr
}

// $GOROOT/src/runtime/runtime.go
func stackalloc(n uint32) stack {
// Stackalloc must be called on scheduler stack, so that we
// never try to grow the stack during the code that stackalloc runs.
// Doing so would cause a deadlock (issue 1547).
thisg := getg()
if thisg != thisg.m.g0 {
throw("stackalloc not on scheduler stack")
}
if n&(n-1) != 0 {
throw("stack size not a power of 2")
}
if stackDebug >= 1 {
print("stackalloc ", n, "\n")
}

if debug.efence != 0 || stackFromSystem != 0 {
n = uint32(alignUp(uintptr(n), physPageSize))
v := sysAlloc(uintptr(n), &memstats.stacks_sys)
if v == nil {
throw("out of memory (stackalloc)")
}
return stack{uintptr(v), uintptr(v) + uintptr(n)}
}

// Small stacks are allocated with a fixed-size free-list allocator.
// If we need a stack of a bigger size, we fall back on allocating
// a dedicated span.
var v unsafe.Pointer
if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
order := uint8(0)
n2 := n
for n2 > _FixedStack {
order++
n2 >>= 1
}
var x gclinkptr
if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != "" {
// thisg.m.p == 0 can happen in the guts of exitsyscall
// or procresize. Just get a stack from the global pool.
// Also don't touch stackcache during gc
// as it's flushed concurrently.
lock(&stackpool[order].item.mu)
x = stackpoolalloc(order)
unlock(&stackpool[order].item.mu)
} else {
c := thisg.m.p.ptr().mcache
x = c.stackcache[order].list
if x.ptr() == nil {
stackcacherefill(c, order)
x = c.stackcache[order].list
}
c.stackcache[order].list = x.ptr().next
c.stackcache[order].size -= uintptr(n)
}
v = unsafe.Pointer(x)
} else {
var s *mspan
npage := uintptr(n) >> _PageShift
log2npage := stacklog2(npage)

// Try to get a stack from the large stack cache.
lock(&stackLarge.lock)
if !stackLarge.free[log2npage].isEmpty() {
s = stackLarge.free[log2npage].first
stackLarge.free[log2npage].remove(s)
}
unlock(&stackLarge.lock)

lockWithRankMayAcquire(&mheap_.lock, lockRankMheap)

if s == nil {
// Allocate a new stack from the heap.
s = mheap_.allocManual(npage, spanAllocStack)
if s == nil {
throw("out of memory")
}
osStackAlloc(s)
s.elemsize = uintptr(n)
}
v = unsafe.Pointer(s.base())
}

if raceenabled {
racemalloc(v, uintptr(n))
}
if msanenabled {
msanmalloc(v, uintptr(n))
}
if asanenabled {
asanunpoison(v, uintptr(n))
}
if stackDebug >= 1 {
print(" allocated ", v, "\n")
}
return stack{uintptr(v), uintptr(v) + uintptr(n)}
}

unsafe.Pointer 与 uintptr

作为Go类型安全层上的一个“后门”,unsafe包在带来强大的低级编程能力的同时,也极容易导致代码出现错误。而出现浙西错误的原因主要就是对unsafe.Pointer和uintptr的理解不到位。因此正确理解unsafe.Pointer和uintptr对于安全使用unsafe包非常有必要。

Go语言内存管理是基于垃圾回收的,垃圾回收会定期进行,如歌一块内存没有被任何对象引用,他就会被垃圾回收器回收掉,而对象引用是通过指针实现的。

unsafe.Pointer 和其他常规类型的指针一样,可以作为对象引用。如果一个对象仍然被某个unsafe.Pointer 变量引用着则该对象不会被回收(垃圾回收)。

即使他存储的是某个对象的内存地址值,它也不会被算作该对象的被算作对该对象的引用

如果认为将对象地址存储在一个uintptr变量中,该对象就不会被垃圾回收器回收,那是对uintptr的最大误解

安全使用

我们既需要unsafe.Pointer打破类型的安全屏障,又需要器能够被安全的使用。如下有几条安全法则

*T1 -> unsafe.Pointer -> *T2

其本质就是内存重解释,将原本解释为T1的类型内存重新解释为T2类型。

这是unsafe.Pointer 突破Go类型安全的屏障的基本使用模式

注意:转换后类型T2, 对对其系数不能比转换前类型T1的对其系数更严格,即Alignof(T1) => Alignof(T2)

unsafe.Pointer -> uintptr

将unsafe.Pointer 显示转换为uinptr,并且转换后的uintptr类型不会在转换回unsafe.Pointer 只用于打印输出,并不参与其他操作。

模拟指针运算

操作任意内存地址上的数据都离不开指针运算。Go常规语法不支持指针运算,但我们可以使用unsafe.Pointer的第三种安全使用模式来模拟指针运算,即在一个表达式中,将unsafe.Pointer转换为uintptr类型,使用uintptr类型的值进行算术运算后,再转换回unsafe.Pointer

经常用于访问结构体内字段或数组中的元素,也常用于实现对某内存对象的步进式检查

注意事项:

  • 不要越界offset理论上可以是任意值,这就存在算术运算之后的地址超出原内存对象边界的可能。 经过转换后,p指向的地址已经超出原数组a的边界,访问这块内存区域是有风险的,尤其是当你尝试去修改它的时候。
  • unsafe.Pointer -> uintptr -> unsafe.Pointer的转换要在一个表达式中

调用syscall.Syscall系列函数时指针类型到uintptr类型参数的转换

将reflect.Value.Pointer或reflect.Value.UnsafeAddr转换为指针

reflect.SliceHeader和reflect.StringHeader必须通过unsafe.Pointer -> uintptr构建

Tips:

使用unsfa包前,请牢记并理解unsafe.Pointer 的六条安全使用模式

如果使用了unsafe包,请使用go vet 等工具对代码进行unsafe包使用合规性检查

go

Go code tips

使用 pkg/errors

https://pkg.go.dev/github.com/pkg/errors

我们在一个项目中使用错误机制,最核心的几个需求是什么?我觉得主要是这两点:

  • 附加信息:我们希望错误出现的时候能附带一些描述性的错误信息,甚至这些信息是可以嵌套的
  • 附加堆栈:我们希望错误不仅仅打印出错误信息,也能打印出这个错误的堆栈信息,可以知道出错的具体代码。

在 Go 语言的演进过程中,error 传递的信息太少一直是被诟病的一点。使用官方的 error 库,只能打印一条简单的错误信息,而没有更多的信息辅助快速定位错误。所以,推荐在应用层使用 github.com/pkg/errors
来替换官方的 error 库。因为使用 pkg/errors,我们不仅能传递出标准库 error 的错误信息,还能传递出抛出 error 的堆栈信息。

官方示例代码如下

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
package main

import (
"fmt"

"github.com/pkg/errors"
)

func fn() error {
e1 := errors.New("error")
e2 := errors.Wrap(e1, "inner")
e3 := errors.Wrap(e2, "middle")
return errors.Wrap(e3, "outer")
}

func main() {
type stackTracer interface {
StackTrace() errors.StackTrace
}

err, ok := errors.Cause(fn()).(stackTracer)
if !ok {
panic("oops, err does not implement stackTracer")
}

st := err.StackTrace()
fmt.Printf("%+v", st[0:2]) // top two frames

// Example output:
// github.com/pkg/errors_test.fn
// /home/dfc/src/github.com/pkg/errors/example_test.go:47
// github.com/pkg/errors_test.Example_stackTrace
// /home/dfc/src/github.com/pkg/errors/example_test.go:127
}

在初始化 slice 的时候尽量补全 cap

当我们要创建一个 slice 结构,并且往 slice 中 append 元素的时候,我们可能有两种写法来初始化这个 slice。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 直接使用[]int 的方式来初始化
package main

import "fmt"

func main() {
arr := []int{}
arr = append(arr, 1, 2, 3, 4, 5)
fmt.Println(arr)
}

// 使用 make 关键字来初始化

package main

import "fmt"

func main() {
arr := make([]int, 0, 5)
arr = append(arr, 1, 2, 3, 4, 5)
fmt.Println(arr)
}

方法二相较于方法一,就只有一个区别:在初始化[]int slice 的时候在 make 中设置了 cap 的长度,就是 slice 的大小。

而且,这两种方法对应的功能和输出结果是没有任何差别的,但是实际运行的时候,方法二会比方法一少运行了一个 growslice 的命令,能够提升我们程序的运行性能。具体我们可以打印汇编码查看一下。

这个 growslice 的作用就是扩充 slice 容量,每当我们的 slice 容量小于我们需要使用的 slice 大小,这个函数就会被触发。

它的机制就好比是原先我们没有定制容量,系统给了我们一个能装两个鞋子的盒子,但是当我们装到第三个鞋子的时候,这个盒子就不够了,我们就要换一个盒子,而换这个盒子,我们势必还需要将原先的盒子里面的鞋子也拿出来放到新的盒子里面。

而 growsslice 的操作是一个比较复杂的操作,它的表现和复杂度会高于最基本的初始化 make 方法。对追求性能的程序来说,应该能避免就尽量避免。如果你对 growsslice 函数的具体实现感兴趣,你可以参考源码 src 的 runtime/slice.go 。

当然,并不是每次都能在 slice 初始化的时候,就准确预估到最终的使用容量,所以我这里说的是“尽量补全 cap”。明白是否设置 slice 容量的区别后,我们在能预估容量的时候,请尽量使用方法二那种预估容量后的 slice 初始化方式。

初始化一个类的时候,如果类的构造参数较多,尽量使用 Option 写法

遇到一定要初始化一个类的时候,大部分时候都会使用类似下列的 New 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Foo struct {
name string
id int
age int

db interface{}
}

func NewFoo(name string, id int, age int, db interface{}) *Foo {
return &Foo{
name: name,
id: id,
age: age,
db: db,
}
}

在这段代码中,我们定义一个 NewFoo 方法,其中存放初始化 Foo 结构所需要的各种字段属性。这个写法乍看之下是没啥问题的,但是一旦 Foo 结构内部的字段发生了变化,增加或者减少了,那么这个初始化函数 NewFoo
就怎么看怎么别扭了。参数继续增加?那么所有调用了这个 NewFoo 方法的地方也都需要进行修改,且按照代码整洁的逻辑,参数多于 5 个,这个函数就很难使用了。而且,如果这 5 个参数都是可有可无的参数,就是有的参数可以不填写,有默认值,比如
age 这个字段,即使我们不填写,在后续的业务逻辑中可能也没有很多影响,那么我在实际调用 NewFoo 的时候,age 这个字段还需要传递 0 值:

1
foo := NewFoo("payne", 1, 0, nil)

乍看这行代码,你可能会以为我创建了一个 Foo,它的年龄为 0,但是实际上是希望表达这里使用了一个“缺省值”,这种代码的语义逻辑就不对了。

这里其实有一种更好的写法:使用 Option 写法来进行改造

Option 写法,顾名思义,就是将所有可选的参数作为一个可选方式,一般我们会设计一个“函数类型”来代表这个
Option,然后配套将所有可选字段设计为一个这个函数类型的具体实现。在具体的使用的时候,使用可变字段的方式来控制有多少个函数类型会被执行。比如上述的代码,我们会改造为:

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

type Foo struct {
name string
id int
age int

db interface{}
}

// FooOption 代表可选参数
type FooOption func(foo *Foo)

// WithName 代表Name为可选参数
func WithName(name string) FooOption {
return func(foo *Foo) {
foo.name = name
}
}

// WithAge 代表age为可选参数
func WithAge(age int) FooOption {
return func(foo *Foo) {
foo.age = age
}
}

// WithDB 代表db为可选参数
func WithDB(db interface{}) FooOption {
return func(foo *Foo) {
foo.db = db
}
}

// NewFoo 代表初始化
func NewFoo(id int, options ...FooOption) *Foo {
foo := &Foo{
name: "default",
id: id,
age: 10,
db: nil,
}
for _, option := range options {
option(foo)
}
return foo
}

我们创建了一个 FooOption 的函数类型,这个函数类型代表的函数结构是 func(foo *Foo) 。

这个结构很简单,就是将 foo 指针传递进去,能让内部函数进行修改。然后我们针对三个初始化字段 name,age,db 定义了三个返回了 FooOption 的函数,负责修改它们:

  • WithName
  • WithAge
  • WithDB

以 WithName 为例,这个函数参数为 string,返回值为 FooOption。在返回值的 FooOption 中,根据参数修改了 Foo 指针。

1
2
3
4
5
6
// WithName 代表Name为可选参数
func WithName(name string) FooOption {
return func(foo *Foo) {
foo.name = name
}
}

有兴趣可以看看知名爬虫框架colly-https://github.com/gocolly/colly/blob/master/colly.go#L55,构造

巧用大括号控制变量作用域

在写 Go 的过程中,你一定有过为 := 和 = 烦恼的时刻。一个变量,到写的时候,我还要记得前面是否已经定义过了,如果没有定义过,使用 := ,如果已经定义过,使用 =。

当然很多时候你可能并不会犯这种错误,如果变量命名得比较好的话,我们是很容易记得这个变量前面是否有定义过的。但是更多时候,对于 err 这种通用的变量名字,你可能就不一定记得了。

这个时候,巧妙使用大括号,就能很好避免这个问题。

小结

  • 使用 pkg/error 而不是官方 error 库
  • 在初始化 slice 的时候尽量补全 cap
  • 初始化一个类的时候,如果类的构造参数较多,尽量使用 Option 写法
  • 巧用大括号控制变量作用域

这几种写法和注意事项都是我在工作和阅读开源项目中的一些总结和经验,每个经验都是对应为了解决不同的问题。虽然说 Go 已经对代码做了不少的规范和优化,但是好的代码和不那么好的代码还是有一些差距的,这些写法优化点就是其中一部分。

阅读

少有人走的路

我们在生活中经常会评价一个人说这个人不成熟像个孩子,那到底什么成熟,一个人成熟与否最重要的是自律性然后是否懂得什么是真正的爱。就是当一个人学会自律,主动的要求自己用积极的心态去面对承受痛苦解决问题的时候那就是距离成熟的距离很近了。而且我们生活中大量的人就宁愿在不成熟当中徘徊宁愿在痛苦当中打转,就是不愿意走上这少有人走的路。走上这条用自律和爱,铸就通向心灵承受的道路。

自律是解决人生问题的最重要的主要工具

全面的自律

不自律的表现

遇到问题拖延着不去主动解决回避问题,假装看不见用药物、酒精等成瘾物麻痹自己,换得一时解脱

实现自律的四个原则

推迟满足感:父母没耐心教孩子,而企图靠吼或打骂的方式一下子解决问题。童年得不到父母爱的孩子容易缺乏安全感,而陷入“今朝有酒今朝醉”处事模式儿无法做到延迟满足。

承担责任:必须面对自己的问题,并承担解决问题的责任

  • 神经官能症:患者总是为自己增加责任
  • 人格失调症:患者总是为自己推卸责任

忠于事实

越是了解事实,处理问题就越得心应手

移情:固守自己已成型的“人生地图”用来解决一切事,不肯改变

保持平衡:确立富有弹性的束缚机制来平衡以上三个原则保持平衡的最高原则,是放弃

爱是是自律的动力

爱的定义

爱上人们自律的原动力,是一种未来促进自己和他人心智成熟,而不断拓展再叫我界限,实现自我完善的意愿

爱的表现

当事人意识和潜意识中目标一致。 是一个人长期、渐进的过程,应用心灵不断的成长和心智不断成熟

爱他人者必自爱, 爱需要付出努力

爱一个人必须付诸行动,想爱,爱的感觉和口头的爱都不等于去爱

非爱的情况

堕入情网

堕入情网不是出于主观臆断,不是有计划、有一事的选择

堕入情网不需要付出努力,并没有真正拓展自我界限

堕入情网不是出于可以消除寂寞,但无法有目的促进心灵

过分依赖

没有他/她我就活不下去,是一种寄生心理,而不是爱症状有忍受的寂寞,空虚感强烈

把失去伴侣当成极其恐怖的事情

精神灌注

不能给心灵带来滋养的精神关注都不是爱

爱金钱、爱权利、爱宠物、爱园艺都被称为“爱”

如果这些爱好中增长了知识,活的了自我界限的拓宽,是爱,否则就不是爱

自我牺牲

给予者以 “爱”作为幌子,满足自己的付出欲。不将对方的心智成熟当回事

爱的体现

关注:努力聆听,帮助对方成长

不惧风险:不要因为害怕失去就放弃爱

独立:人生唯一的安全感,来自于成分体验人生的不安全感保持自己独立,也许伴侣独立

充分透入:由承担来推动,持续或渐渐增多的投入是爱的基石,给对方以安全感

勤于自律,平等交流:当感觉矛盾与冲突时,用恰当的语言提出想法,而非使用自大的态度随意批评

懂得自律:真正的爱必然懂得约束自己,并促进双发心智的成熟,真正的爱对自己的约束力,容不下第三者,对伴侣和孩子和孩子负有责任感

自我界限

自我界限的设立

0-7个月,婴儿分辨与外部 世界

1岁:婴儿开始意识到自己和外界的区别,明白“我”是什么,什么不是“我”

2-3岁:从界限不明,以为自己无所不能,到开始明白自己能力有限,有了自我界限,开始感到孤独

自我界限的暂时崩溃

坠入情网意味着意味着自我界限的某一部分突然崩溃,人有体会到了小时候无所不能的感觉,所以兴奋不已。等到冷静下来,发现彼此的问题,各自的自我界限又再次合拢

自我界限的拓宽

走出舒适区,努力为一件事情付出,对自我边界形成永久性的扩张,实现自我正常

信仰与恩典

盲目地认为宗教信仰有助于或有害于个人心智的成熟与健康,都不是我们应有的态度,因人而异,因事而异。

生命中出现的恩典与奇迹,虽不能尽数解释,也应当理智对待。

阻碍成熟的障碍—懒惰

逃避痛苦的原动力

恐惧直面问题的麻烦和痛苦,是懒惰的深层原因

GC

如何判断对象是垃圾

经典判断方法 1:引用计数法

思路很简单,但是如果出现循环引用,即 A 引用 B,B 又引用 A,这种情况下就不好办了。所以一般还使用了另一种称为“可达性分析”的判断方法。

经典判断方法 2:可达性分析

如果 A 引用 B,B 又引用 A(发生了循环引用问题),这 2 个对象是否能被 GC回收?

关键不是在于 A、B 之间是否有引用,而是 A、B 是否可以一直向上追溯到 GC Roots。如果与 GC Roots 没有关联,则会被回收;否则,将继续存活。

上图是一个用“可达性分析”标记垃圾对象的示例图,灰色的对象表示不可达对象,将等待回收

哪些内存区域需要 GC

常用的 4 种 GC 算法

mark-sweep 标记清除法

黑色区域表示待清理的垃圾对象,标记出来后直接清空。

优:简单快速;

缺:产生很多内存碎片。

mark-copy 标记复制法

思路也很简单,将内存对半分,总是保留一块空着(上图中的右侧),将左侧存活的对象(浅灰色区域)复制到右侧,然后左侧全部清空。

优:避免了内存碎片问题;

缺:内存浪费很严重,相当于只能使用 50% 的内存。

mark-compact 标记-整理(也称标记-压缩)法

将垃圾对象清理掉后,同时将剩下的存活对象进行整理挪动(类似于 windows 的磁盘碎片整理),保证它们占用的空间连续。

优:节约了内存,并避免了内存碎片问题。

缺:整理过程会降低 GC 的效率。

上述三种算法,每种都有各自的优缺点,都不完美;在现代 JVM 中,往往是综合使用的。经过大量实际分析,发现内存中的对象,大致可以分为两类:

有些生命周期很短,比如一些局部变量/临时对象;

而另一些则会存活很久,典型的比如 websocket 长连接中的 connection 对象。如下图,纵向 y 轴可以理解分配内存的字节数,横向 x 轴理解为随着时间流逝(伴随着 GC)。

可以发现大部分对象其实相当短命,很少有对象能在 GC 后活下来,因此诞生了分代的思想。

generation-collect 分代收集算法

如下图所示,可以将内存分成了三大块:年青代(Young Genaration)、老年代(Old Generation)、永久代(Permanent Generation)。其中 Young Genaration 更是又细为分
eden、S0、S1 三个区。

结合我们经常使用的一些 jvm 调优参数后,一些参数能影响的各区域内存大小值,示意图如下:

GC 的主要过程

刚开始时,对象分配在 eden 区,s0(即:from)及 s1(即:to)区几乎是空着。

随着应用的运行,越来越多的对象被分配到 eden 区。

当 eden 区放不下时,就会发生 minor GC(也被称为 young GC)。

首先当然是要先标识出不可达垃圾对象(即下图中的黄色块);

然后将可达对象,移动到 s0 区(即:4个淡蓝色的方块挪到s0区);

然后将黄色的垃圾块清理掉,这一轮后 eden 区就成空的了。

注:这里其实已经综合运用了“【标记-清理eden】+【标记-复制 eden->s0】”算法。

随着时间推移,eden 如果又满了,再次触发 minor GC,同样还是先做标记,这时 eden 和 s0 区可能都有垃圾对象了(下图中的黄色块)。

这时 s1(即:to)区是空的,s0 区和 eden 区的存活对象,将直接搬到 s1 区。

然后将 eden 和 s0 区的垃圾清理掉,这一轮 minor GC 后,eden 和 s0 区就变成了空的了。

继续随着对象的不断分配,eden 空可能又满了,这时会重复刚才的 minor GC 过程。不过要注意的是:

这时候 s0 是空的,所以 s0 与 s1 的角色其实会互换。即存活的对象,会从 eden 和 s1 区,向 s0 区移动。

然后再把 eden 和 s1 区中的垃圾清除,这一轮完成后,eden 与 s1 区变成空的,如下图。

代龄与晋升

对于那些比较“长寿”的对象一直在 s0 与 s1 中挪来挪去,一来很占地方,而且也会造成一定开销,降低 gc 效率,于是有了“代龄(age)”及“晋升”。

对象在年青代的 3 个区(edge,s0,s1)之间,每次从一个区移到另一区,年龄 +1,在 young 区达到一定的年龄阈值后,将晋升到老年代。

下图中是 8,即挪动 8 次后,如果还活着,下次 minor GC 时,将移动到 Tenured 区。

晋升的主要过程

下图是晋升的主要过程:对象先分配在年青代,经过多次 Young GC 后,如果对象还活着,晋升到老年代。

如果老年代,最终也放满了,就会发生 major GC(即 Full GC)。由于老年代的的对象通常会比较多,标记-清理-整理(压缩)的耗时通常也会比较长,会让应用出现卡顿的现象。这也就是为什么很多应用要优化,尽量避免或减少 Full GC
的原因。

注:上面的过程主要来自 oracle 官网的资料,但是有一个细节官网没有提到:如果分配的新对象比较大,eden 区放不下,但是 old 区可以放下时,会直接分配到 old 区。

即没有晋升这一过程,直接到老年代了。

GC 流程图

8 种垃圾回收器

这些回收器都是基于分代的,把 G1 除外,按回收的分代划分如下。

横线以上的 3 种:Serial、ParNew、Parellel Scavenge 都是回收年青代的;

横线以下的 3 种:CMS、Serial Old、Parallel Old 都是回收老年代的。

接下来,我们将以上提到的 8 种垃圾回收器逐一讲解,其中 CMS、G1、ZGC 这三种收集器是面试考试重点,我也会着重讲解。

Serial 收集器

单线程用标记-复制算法,快刀斩乱麻,单线程的好处避免上下文切换,早期的机器,大多是单核,也比较实用。但执行期间会发生 STW(Stop The World)。

ParNew 收集器

Serial 的多线程版本,也同样会 STW,在多核机器上会更适用。

Parallel Scavenge 收集器

ParNew 的升级版本,主要区别在于提供了两个参数:

-XX:MaxGCPauseMillis 最大垃圾回收停顿时间;

-XX:GCTimeRatio 垃圾回收时间与总时间占比。

通过这 2 个参数,可以适当控制回收的节奏,更关注于吞吐率,即总时间与垃圾回收时间的比例。

Serial Old 收集器

因为老年代的对象通常比较多,占用的空间通常也会更大。如果采用复制算法,得留 50% 的空间用于复制,相当不划算;而且因为对象多,从一个区,复制到另一个区,耗时也会比较长。

所以老年代的收集,通常会采用“标记-整理”法。从名字就可以看出来,这是单线程(串行)的, 依然会有 STW。

Parallel Old 收集器

一句话:Serial Old 的多线程版本。

CMS 收集器

Concurrent Mark Sweep,从名字上看,就能猜出它是并发多线程的。这是 JDK 7 中广泛使用的收集器,有必要多说一下。

G1 收集器

鉴于 CMS 的一些不足之外,比如:老年代内存碎片化,STW 时间虽然已经改善了很多,但是仍然有提升空间。G1 就横空出世了,它对于 heap 区的内存划思路很新颖,有点算法中分治法“分而治之”的味道。

G1 的全称是 Garbage-First

G1 垃圾收集器的原理

如下图,G1 将 heap 内存区,划分为一个个大小相等(1-32M,2的 n 次方)、内存连续的 Region 区域,每个 region 都对应 Eden、Survivor 、Old、Humongous 四种角色之一,但是 region
与 region 之间不要求连续。

注:Humongous,简称 H 区是专用于存放超大对象的区域,通常 >= 1/2 Region Size,且只有 Full GC 阶段,才会回收 H 区,避免了频繁扫描、复制/移动大对象。

所有的垃圾回收,都是基于 1 个个 region 的。JVM 内部知道,哪些 region 的对象最少(即该区域最空),总是会优先收集这些 region(因为对象少,内存相对较空,肯定快)。这就是 Garbage-First 得名的由来,G
即是 Garbage 的缩写,1 即 First。

  1. G1 Young GC young GC 前:

young GC 后:

理论上讲,只要有一个 Empty Region(空区域),就可以进行垃圾回收。

由于 region 与 region 之间并不要求连续,而使用 G1 的场景通常是大内存,比如 64G 甚至更大,为了提高扫描根对象和标记的效率,G1 使用了二个新的辅助存储结构:

  • Remembered Sets:简称 RSets,用于根据每个 region 里的对象,是从哪指向过来的(即谁引用了我),每个 Region 都有独立的 RSets(Other Region -> Self Region)。

  • Collection Sets :简称 CSets,记录了等待回收的 Region 集合,GC 时这些 Region 中的对象会被回收(copied or moved)。

RSets 的引入,在 YGC 时,将年青代 Region 的 RSets 做为根对象,可以避免扫描老年代的 region,能大大减轻 GC 的负担。

注:在老年代收集 Mixed GC 时,RSets 记录了 Old->Old 的引用,也可以避免扫描所有 Old 区。

Old Generation Collection(也称为 Mixed GC)

按 oracle 官网文档描述,分为 5 个阶段:Initial Mark(STW) -> Root Region Scan -> Cocurrent Marking -> Remark(STW) -> Copying/Cleanup(
STW && Concurrent)

注:也有很多文章会把 Root Region Scan 省略掉,合并到 Initial Mark 里,变成 4 个阶段。

阶段 1:存活对象的“初始标记”依赖于 Young GC,GC 日志中会记录成 young 字样。

阶段 2:并发标记过程中,如果发现某些 region 全是空的,会被直接清除。

阶段 3:进入重新标记阶段。

阶段 4:并发复制/清查阶段。这个阶段,Young 区和 Old 区的对象有可能会被同时清理。GC 日志中,会记录为 mixed 字段,这也是 G1 的老年代收集,也称为 Mixed GC 的原因。

上图是,老年代收集完后的示意图。

通过这几个阶段的分析,虽然看上去很多阶段仍然会发生 STW,但是 G1 提供了一个预测模型,通过统计方法,根据历史数据来预测本次收集,需要选择多少个 Region 来回收,尽量满足用户的预期停顿值(-XX:MaxGCPauseMillis
参数可指定预期停顿值)。

注:如果 Mixed GC 仍然效果不理想,跟不上新对象分配内存的需求,会使用 Serial Old GC(Full GC)强制收集整个 Heap。

小结:与 CMS 相比,G1 有内存整理过程(标记-压缩),避免了内存碎片;STW 时间可控(能预测 GC 停顿时间)。

ZGC(截止目前史上最好的 GC 收集器)

在 G1 的基础上,它做了如下 7 点改进

动态调整大小的 Region
G1 中每个 Region 的大小是固定的,创建和销毁 Region,可以动态调整大小,内存使用更高效。

不分代,干掉了 RSets
G1 中每个 Region 需要借助额外的 RSets 来记录“谁引用了我”,占用了额外的内存空间,每次对象移动时,RSets 也需要更新,会产生开销。

带颜色的指针 Colored Pointer

这里的指针类似 Java 中的引用,意为对某块虚拟内存的引用。ZGC 采用了64位指针(注:目前只支持 linux 64 位系统),将 42-45 这 4 个 bit 位置赋予了不同含义,即所谓的颜色标志位,也换为指针的 metadata。

  • finalizable 位:仅 finalizer(类比 C++ 中的析构函数)可访问;

  • remap 位:指向对象当前(最新)的内存地址,参考下面提到的relocation;

  • marked0 && marked1 位:用于标志可达对象。

这 4 个标志位,同一时刻只会有 1 个位置是 1。每当指针对应的内存数据发生变化,比如内存被移动,颜色会发生变化。

读屏障 Load Barrier

传统 GC 做标记时,为了防止其他线程在标记期间修改对象,通常会简单的 STW。而 ZGC 有了 Colored Pointer 后,引入了所谓的“读屏障”。

当指针引用的内存正被移动时,指针上的颜色就会变化,ZGC 会先把指针更新成最新状态,然后再返回(你可以回想下 Java 中的 volatile 关键字,有异曲同工之妙)。这样仅读取该指针时,可能会略有开销,而不用将整个 heap STW。

重定位 Relocation

如下图,在标记过程中,先从 Roots 对象找到了直接关联的下级对象 1,2,4。

然后继续向下层标记,找到了 5,8 对象, 此时已经可以判定 3,6,7 为垃圾对象。

如果按常规思路,一般会将 8 从最右侧的 Region,移动或复制到中间的 Region,然后再将中间 Region 的 3 干掉,最后再对中间 Region 做压缩 compact 整理。

但 ZGC 做得更高明,它直接将 4,5 复制到了一个空的新 Region 就完事了,然后中间的 2 个 Region 直接废弃,或理解为“释放”,作为下次回收的“新” Region。这样的好处是避免了中间 Region 的 compact
整理过程。

最后,指针重新调整为正确的指向(即:remap),而且上一阶段的 remap 与下一阶段的mark是混在一起处理的,相对更高效。

多重映射 Multi-Mapping

这个优化,说实话没完全看懂,只能谈下自己的理解(如果有误,欢迎指正)。虚拟内存与实际物理内存,OS 会维护一个映射关系,才能正常使用,如下图:

zgc 的 64 位颜色指针,在解除映射关系时,代价较高(需要屏蔽额外的 42-45 的颜色标志位)。考虑到这 4 个标志位,同 1 时刻,只会有 1 位置成 1(如下图),另外 finalizable
标志位,永远不希望被解除映射绑定(可不用考虑映射问题)。

所以剩下 3 种颜色的虚拟内存,可以都映射到同1段物理内存。即映射复用,或者更通俗点讲,本来 3 种不同颜色的指针,哪怕 0-41 位完全相同,也需要映射到 3 段不同的物理内存,现在只需要映射到同 1 段物理内存即可。

支持 NUMA 架构

NUMA 是一种多核服务器的架构,简单来讲,一个多核服务器(比如 2core),每个 cpu 都有属于自己的存储器,会比访问另一个核的存储器会慢很多(类似于就近访问更快)。

相对之前的 GC 算法,ZGC 首次支持了 NUMA 架构,申请堆内存时,判断当前线程属是哪个CPU在执行,然后就近申请该 CPU 能使用的内存。

小结:革命性的 ZGC 经过上述一堆优化后,每次 GC 总体卡顿时间按官方说法<10ms。

注:启用 zgc,需要设置 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC。

Remap 的流程图

抓包

雷电模拟器抓包配置

爬虫免不了抓包分析,当然也免不了中间转发相关的便捷操作。如果有一个稳定的、妥善的抓包环境。相信在爬虫开发中一定会事半功倍。

有真机当然是最好的,同时如果爬虫开发者并没有真机。那么模拟器变成为了不二之选。当然如果支持多开的话那就更好了。

业界比较知名的有

MuMu模拟器:https://mumu.163.com/

雷电模拟器:https://www.ldmnq.com/

夜神模拟器:https://www.yeshen.com/

在开发爬虫的时候,我个人使用的比较多的的是雷电模拟器,因为他支持多开(虽然这之间有限制),以及抓包也有些问题。但奈何,它支持多开啊。配合 Mitmproxy的组件譬如Mitmdump
简直不要太舒服。

雷电模拟器的安装

首先进入官网, 如下图所示,https://www.ldmnq.com/

image-20220425015741050

选择更新日志,寻找历史版本。笔者这里使用的是3.74 较为稳定。以及支持网桥。

image-20220425020045161

https://dl.softmgr.qq.com/original/game/ldinst_3.74.0.exe

选择3.74版本,主要是由于新版本的网桥这个bug在修复。以及完善度如何,至今不敢恭维。so,就选择较为稳定的它。

必须需要支持网桥,否则无法抓包。

抓包工具安装

抓包工具这里没什么好讲的,常规常见的都都行。

fiddle:https://www.telerik.com/download/fiddler

Charles: https://www.charlesproxy.com/

mitmproxy:https://www.mitmproxy.org/

都比较不错,具体选择那个请你酌情考虑。

雷电模拟器抓包配置

首先新建一个雷电模拟器,如下图所示。以及网桥设置如下。

当然记得安装桥接的驱动

image-20220425020758810

完成后,如下所示

image-20220425020954288

以Mitmproxy的证书为例子,如下

首先需要安装mitmproxy,执行如下命令(在此之前需要有Python环境)

1
pip install mitmproxy

如果配置了环境变量的话,首次执行一下mitmproxy相关的命令(由于非Uinux,是没有mitmrpoxy的),所以执行mitmweb, 我个人建议是添加对应的端口。命令如下所示

1
mitmweb -p 8088

当然,我建议是8088,我习惯给他配置为mitm相关的抓包的端口。当然你可以选择其他的端口,当然后续操作就可能会有些许出入。

此时,进入~.mitmproxy 目录下

1
2
3
4
# windows
cd ~\.\.mitmproxy\
# mac or Linux
cd ~/.mitmproxy/

使用ls 命令即可查看到相关的证书了!。我建议是在这里寻找到证书而非mitm.it,因为在手机上很有可能上不去

配置代理

在配置证书前,我强烈建议你先配置好代理端口。这将大大到降低与简化后续的操作。由于模拟器都是采用的自生的网络。直接配置WI-FI即可。如下

image-20220425022132093

点击修改网络,以及高级配置,同时将代理选择为手动。如下图所示

image-20220425022206837

端口在这里我设置为8088, 代理服务器主机名。你可以在终端中的ifconfig 或者ipconfig,中获取。在此便不再过多赘述。

image-20220425022253389

配置证书

~.mitmproxy下将昵称为mitmproxy-ca-cert.pem的文件拖入到雷电模拟器中,如下图所示

将该文件移动屏幕内,即可跳转到该目录

image-20220425022448328

开始配置证书,打开设置-> 安全-> 从SD卡安装 选择证书所在的目录。双击即可安装

在终端中运行

1
mitmweb -p 8088

浏览百度

image-20220425022954673

至此,证书就已经安装完成啦

Android 7 及以上的版本抓包

1
2
3
4
adb shell
su
mount -o rw,remount /system
cp /data/misc/user/0/cacerts-added/269953fb.0 /system/etc/security/cacerts/

Referer

Android(4) Android7.0 配置系统证书

command

在Linux上有这样一个需求,需要将Python的默认版本设置为python3.8 但由于Linux系统自带的是3.6.那么实现他只需要完成python3.8的安装,以及python命令的指向问题。

python3.8 的安装在此便不再过多赘述,如下命令都可任选其一即可

1
2
3
sudo yum install -y python38 python38-pip

sudo dnf install -y python38 python38-pip

修改指向

alternatives 修改

1
alternatives --config python3

此时直接输入1,修改即可。如下图所示

image-20220415024725606

alternatives —install

1
2
alternatives --install <链接> <名称> <路径> <优先度>
update-alternatives --install /usr/bin/python python /usr/bin/python2.7 2

软链接

1
ln -fs /usr/bin/python3.8 /usr/bin/python3