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

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


  • 首页

  • 归档

  • 搜索

git命令系列 --> git stash

发表于 2020-12-02

一、git stash save “save message” : 执行存储时,添加备注,方便查找,只有git stash 也要可以的,但查找时不方便识别。

(2)git stash list :查看stash了哪些存储

(3)git stash show :显示做了哪些改动,默认show第一个存储,如果要显示其他存贮,后面加stash@{$num},比如第二个 git stash show stash@{1} 从0开始

(4)git stash show -p : 显示第一个存储的改动,如果想显示其他存存储,命令:git stash show stash@{$num} -p ,比如第二个:git stash show stash@{1} -p

(5)git stash apply :应用某个存储,但不会把存储从存储列表中删除,默认使用第一个存储,即stash@{0},如果要使用其他个,git stash apply stash@{$num} , 比如第二个:git stash apply stash@{1}

(6)git stash pop :命令恢复之前缓存的工作目录,将缓存堆栈中的对应stash删除,并将对应修改应用到当前的工作目录下,默认为第一个stash,即stash@{0},如果要应用并删除其他stash,命令:git stash pop stash@{$num} ,比如应用并删除第二个:git stash pop stash@{1}

(7)git stash drop stash@{num} :丢弃stash@{num}存储,从列表中删除这个存储

(8)git stash clear :删除所有缓存的stash

————————–end————————-

对你有帮助的话,记得点个赞👍

  • [六十七点五 ]

————————–end————————-

本文转载自: 掘金

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

什么是容器?什么是Namespace ,CGroups,UF

发表于 2020-12-02

前言

当人们聊到容器技术,就会躲不开这几个关键字 namespace, cgroup, ufs,但说完这几个关键字之后貌似就不能往下谈了。在这里将通过通俗易懂的方式直接揭开这几个关键字的含义,及进程是如何使用这些技术达到容器化。

  • 文章涉及一点点golang 代码,但都做了详细解释,不影响理解;

1、Linux Namespace

Namespace 用于对资源的隔离,包括进程隔离,挂载点隔离,网络隔离,用户隔离,程序组进程间通信隔离;
当一个进程被创建的时候,可以使用clone() 创建这些NS,当然也可使用setns() 来加入已存在的NS,最后还可以使用unshare() 进程移出某个NS

Namespace 类型 系统调用参数 内核版本
Mount NS CLONE_NEWNS 2.4.19
UTS NS CLONE_NEWUTS 2.6.19
IPC NS CLONE_NEWIPC 2.6.19
PID NS CLONE_NEWPID 2.6.24
Network NS CLONE_NEWNET 2.6.29
User NS CLONE_NEWUSER 3.8
1.1、环境准备

后面通过代码的实现来了解以上的namespace , 前提需要准备一下环境:
1、centos 7 系统, 内核4.4 以上,因为4.4系统调用方式改了;(需支持aufs的内核版本,后面实现需要)
2、golang 1.12以上;

2、 代码实现NS

2.1、UTS Namespace
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
golang复制代码package main

/*
UTS Namespace 主要用于隔离 node name 和 domain name
*/

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
log.Println("start new namespace")
// 命令
cmd := exec.Command("sh")
// 系统调用clone 进程时,设定新的uts namespace
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
  • 使用以下命令校验
1
2
3
4
5
6
7
8
9
10
11
12
bash复制代码# 执行当前脚本, 进入容器
$ go run uts.go
# 查看进程树
$ pstree -pl
# echo $$
19915
# 查看进程uts namespace 号;
$ readlink /proc/19915/ns/uts
uts:[40287666333]
// 对比另一个shell的hostname,不同则是已经隔离了 hostname 及 domain
$ hostname -b shadow
shadow

2.2、IPC Namespace
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
golang复制代码package main

/*
IPC Namespace 用来隔离 system V IPC 和 POSIX message queues
*/

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
log.Println("start new namespace")
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}


/*
ipcs 命令查看 Message Queues | Shared Memory Segments| Semaphore Arrays
ipcrm 删除
ipcmk 创建

[t1]:
ipcs -q
ipcmk -Q
ipcs -q
[t2]:
ipcs -q
*/

Term1

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码# 执行当前脚本
# 进入容器
$ go run ipc.go
2020/09/16 01:11:47 start new namespace
sh-4.2# ipcs -q
--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息
sh-4.2# ipcmk -Q
消息队列 id:0
sh-4.2# ipcs
--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息
0x6a89a1ad 0 root 644 0 0

Term2

1
2
3
bash复制代码$ ipcs -q
--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息

2.3、PID Namespace
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
golang复制代码package main

/*
PID Namespace 用来隔离进程ID
*/

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
log.Println("start new namespace")
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}


/*
[t1]:
pstree -pl
echo $$

*/

Term1

1
2
3
4
5
6
bash复制代码# 进入容器
$ go run pid.go
2020/09/16 01:18:49 start new namespace
# 可以看到当前的pid 为1
sh-4.2# echo $$
1

2.4、Mount Namespace
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
go复制代码package main

/*
Mount Namespace 主要用于隔离各个进程看到的挂载点
*/

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
log.Println("start new namespace")
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |syscall.CLONE_NEWNS | syscall.CLONE_NEWIPC,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

下面可以看出,挂载/proc到自己的namespace 后,我们只会看到我们自己进程信息;

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
bash复制代码$ go run mount.go
2020/09/16 01:22:14 start new namespace
# 未挂载/proc
sh-4.2# ls /proc
1 20 300 5186 64 6776 79 cpuinfo kallsyms mtrr thread-self
10 21 301 5332 6481 6780 796 crypto kcore net timer_list
1055 22 370 549 65 6783 8 devices keys pagetypeinfo tty
11 23 3925 550 6543 68 80 diskstats key-users partitions uptime
1113 24 3939 553 6570 69 804 dma kmsg sched_debug version
1115 25 396 554 6571 71 850 driver kpagecgroup schedstat vmallocinfo
13 26 397 559 6572 72 9 dynamic_debug kpagecount scsi vmstat
1300 271 4 560 6574 73 93 execdomains kpageflags self zoneinfo
1306 272 419 562 66 74 94 fb loadavg slabinfo
14 274 439 563 6652 75 acpi filesystems locks softirqs
15 275 446 574 67 76 buddyinfo fs mdstat stat
16 276 448 6 6740 77 bus interrupts meminfo swaps
18 283 450 61 6741 780 cgroups iomem misc sys
19 294 452 62 6742 781 cmdline ioports modules sysrq-trigger
2 3 508 63 6745 784 consoles irq mounts sysvipc
# 挂载 /proc 设备后;
sh-4.2# mount -t proc proc /proc
sh-4.2# ps
PID TTY TIME CMD
1 pts/0 00:00:00 sh
5 pts/0 00:00:00 ps
sh-4.2# ls /proc
1 crypto fs kmsg modules self timer_list
6 devices interrupts kpagecgroup mounts slabinfo tty
acpi diskstats iomem kpagecount mtrr softirqs uptime
buddyinfo dma ioports kpageflags net stat version
bus driver irq loadavg pagetypeinfo swaps vmallocinfo
cgroups dynamic_debug kallsyms locks partitions sys vmstat
cmdline execdomains kcore mdstat sched_debug sysrq-trigger zoneinfo
consoles fb keys meminfo schedstat sysvipc
cpuinfo filesystems key-users misc scsi thread-self

2.5、User Namespace
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
go复制代码package main

/*
USER Namespace 主要用于隔离各个用户id及用户组id
*/

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
log.Println("start new namespace")
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{

Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC |syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 5001,
HostID: syscall.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 5001,
HostID: syscall.Getuid(),
Size: 1,
},
},
}
//cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(1), Gid: uint32(1)}

cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr


if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

可以看出当前用户不一致

Term1

1
2
3
4
5
6
7
bash复制代码# 进入容器
$ go run user.go
2020/09/16 01:33:33 start new namespace
sh-4.2$ id
uid=5001 gid=5001 组=5001
sh-4.2$ exit
exit

Term2 物理机

1
2
bash复制代码$ id
uid=0(root) gid=0(root) 组=0(root)

2.6、Network Namespace
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
go复制代码package main

/*
Mount Namespace 主要用于隔离网络空间
*/

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
log.Println("start new namespace")
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID |syscall.CLONE_NEWNS | syscall.CLONE_NEWIPC |
syscall.CLONE_NEWNET,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
  • 进入容器后,看不到有网络配置
1
2
3
bash复制代码$ go run net.go
2020/09/16 01:35:10 start new namespace
sh-4.2# ifconfig

3、Linux Cgroup

什么是Cgroups? Cgroup 提供了对一组进程的资源限制、控制和统计的能力,这些资源包括CPU,内存,存储,网络等。通过Cgroups,可以方便的限制程序占用的资源,并且可以实时的监控进程和统计信息。
Cgroups 中的3个组件

  • cgroup 是对进程分组管理的一种机制,一个cgroup包含一组进程,并且可以在这个cgroup上增加 linux subsystem的各种参数配置,将一组进程和一组subsystem的系统参数关联起来。
  • subsystem 是一组资源控制的模块,一般包含如下配置:
    • blkio 谁知对块设备的输入输出访问控制;
    • cpu 设置cgroup中进程的cpu 被调度的策略;
    • cpuacct 可以统计cgroup中进程的cpu占用;
    • cpuset 在多核机器上设置ccgroup中进程可以使用的cpu
    • devices 控制cgroup中进程对设备的访问;
    • freeze 用去刮起和恢复cgroup 中的进程;
    • memory 用于控制cgroup中的进程内存;
    • net_cls 用于将cgroup中进程产生的网络包分类,以便linux的tc(traffic controller) 可以根据分类区分出来自某个cgroup中的包做限流或监控;
    • net_prio 设置cgroup中的进程产生的网络流量优先级;
    • ns 它的作用是使cgroup中的进程在新的namespace中fork新进程(newns)时,创建一个新的cgroup,这个cgroup包含新的namespaace中的进程;
1
2
3
4
5
6
7
8
9
10
11
12
bash复制代码# // 查询系统中支持的subsystems
$ lssubsys -a
cpuset
cpu,cpuacct
blkio
memory
devices
freezer
net_cls,net_prio
perf_event
hugetlb
pids
  • hierarchy 的功能是把一组cgroup串成一个树状结构,便于继承,相当于默认有一个cgroup根结点,其他cgroup都是该cgroup的子节点;

三个组件的关系

  • 系统创建新的hierarchy之后,系统中所有的进程都会加入到这个hierarchy的cgroup根节点中;
  • 一个subsystem只能附加到一个hierarchy;
  • 一个hierarchy 可以附加多个 subsystem;
  • 一个进程可以作为多个cgroup的成员,但是这些cgroup必须在不同的hierarchy中;
  • 一个进程fork 出子进程时,子进程是和父进程在同一个cgroup中,也可以根据需要移动到其他的cgroup中;
3.1、呈现Cgroup

1、创建并挂载一个hierarcy;

1
2
3
4
bash复制代码$ mkdir cgroup-test
$ mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test/
$ ls ./cgroup-test/
cgroup.clone_children cgroup.procs cgroup.sane_behavior notify_on_release release_agent tasks

这些文件是hierachey的根cgroup节点配置

  • cgroup.clone_children, cpuset 的subsystem会读取这个配置文件,如这个值是1(default 0),子cgroup才会继承父cgroup的cpuset的配置
  • cgroup.procs 是树中当前节点cgroup中的进程ID,现在的位置是在根节点,这个文件中会有现有系统的所有进程组ID;
  • notify_on_rellease 和 release_agent 会一起使用。notify_on_releaase 标示当这个cgroup最后一个进程退出的时候是否执行了release_agent; release_agent 则是一个路径,通常用作进程退出之后自动清理掉不再使用的cgroup;
  • task 标识该cgroup下面的进程ID, 如果把一个进程ID写到tasks文件中,便会将相应的进程加入到这个cgroup中;

2、扩展两个子的cgroup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bash复制代码$ mkdir cgroup-1 cgroup-2
$ tree
.
├── cgroup-1
│ ├── cgroup.clone_children
│ ├── cgroup.procs
│ ├── notify_on_release
│ └── tasks
├── cgroup-2
│ ├── cgroup.clone_children
│ ├── cgroup.procs
│ ├── notify_on_release
│ └── tasks
├── cgroup.clone_children
├── cgroup.procs
├── cgroup.sane_behavior
├── notify_on_release
├── release_agent
└── tasks

3、在cgroup中添加和移动进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bash复制代码$ pwd
/data/docker_lab/online/cgroup-test
$ cat tasks |grep `echo $$`
6935
$ echo $$ > cgroup-1/tasks
$ cat tasks |grep `echo $$`
$ cat /proc/6935/cgroup
12:name=cgroup-test:/cgroup-1 # 可以看到当前进程加入到了cgroup-test:/cgroup-1
11:pids:/
10:memory:/user.slice
9:cpu,cpuacct:/user.slice
8:devices:/user.slice
7:freezer:/
6:blkio:/user.slice
5:perf_event:/
4:cpuset:/
3:hugetlb:/
2:net_cls,net_prio:/
1:name=systemd:/user.slice/user-0.slice/session-449.scope

4、通过subsystem限制cgroup中的进程资源

  • 由于系统早就默认为所有的subsystem创建了一个默认的hierachy,而一个susbsysten只能属于一个hierachy,所以我们只能使用默认的hierachy 来做限制实验;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bash复制代码# // 默认的memory hierachy
$ mount |grep mem
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
# // 系统资源压测命令
$ stress --vm-bytes 500m --vm-keep -m 1
# // 可以看到stress 占用了500m
$ top
# // 新建子cgroup,并进入
$ cd /sys/fs/cgroup/memory ; mkdir test-limit-mem ; cd test-limit-mem
# // 加入限制内存
$ echo 200m > memory.limit_in_bytes
# // 移动当前pid 到cgroup
$ echo $$ > tasks
# // 系统资源压测命令
$ stress --vm-bytes 500m --vm-keep -m 1
# // 可以看到stress 占用了 200m
$ top
3.2、Docker 中使用cgroups
1
2
3
shell复制代码$ docker run -itd -m 128m ubuntu
# // 在里面能找到对应的docker 的唯一ID目录同样在 memory.limit_in_bytes 中设置;
$ ls /sys/fs/cgroup/memory/docker/

4、Union File System

4.1、什么是 Union File System

UFS 是一种把其他文件系统联合到一个联合挂载带你的文件系统服务,它使用branch 把不同文件系统的文件和目录“透明地”覆盖,形成一个单一一致的文件系统。这些branch 或者是read-only 的或这的read-write的,所以当这个虚拟后的联合文件系统进行写操作的时候,系统是真整写到一个新的文件中。看起来这个整个系统是可以对任何文件进行操作,但是并没有改变原来这个文件。因为unionfs 用到一个叫写时复制的技术(COW)。
Copy-on-write, 也叫隐式共享;如果一个资源是重复的,在没有任何修改的前提下,是不需要建立一个新的资源,这个资源可以同时被新旧实例共享;当第一次写操作发生时,会对该资源完整的复制并进行修改;

4.2、AUFS

AUFS 是 UFS 的一种实现,Docker 选用的第一种存储驱动并沿用至今,还有其他同类型驱动,overlay, overlay2, overlyafs 等;AUFS 需要内核支持

查看是否支持aufs

1
2
3
4
5
6
bash复制代码$ cat /proc/filesystems |grep aufs
nodev aufs
# // 如果不支持可以通过切换到支持aufs的内核
$ cd /etc/yum.repos.d/
$ wget https://yum.spaceduck.org/kernel-ml-aufs/kernel-ml-aufs.repo
$ yum install kernel-ml-aufs
  • 内核切换,推荐一个我当时参考: www.cnblogs.com/xzkzzz/p/96…
4.3、Docker 如何使用AUFS

Docker aufs存储目录

  • /var/lib/docker/aufs/diff
    • docker host filesystem 存储在该目录下
  • /var/lib/docker/aufs/layers/
    • docker 的镜像主要存储位置
  • /var/lib/docker/aufs/mnt;
    • 运行时修改的文件内容
4.4、手写aufs

以下是全过程

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
bash复制代码$ pwd
/data/docker_lab/online/aufs
$ ls
changed-ubuntu cn-l iml1 iml2 iml3 mnt
$ cat cn-l/cn-l.txt
I am cn layer
$ cat iml1/iml1.txt
l1
$ cat iml2/iml2.txt
l2
$ cat iml3/iml3.txt
l3
# 挂载aufs
$ mount -t aufs -o dirs=./cn-l/:./iml3:./iml2:./iml1 none ./mnt
$ tree mnt/
mnt/
├── cn-l.txt
├── iml1.txt
├── iml2.txt
└── iml3.txt
# 这是一个mnt的挂载点信息, 只有cn-l 是可读写的
$ cat /sys/fs/aufs/si_d3fb24f591e1278f/*
/data/docker_lab/online/aufs/cn-l=rw
/data/docker_lab/online/aufs/iml3=ro
/data/docker_lab/online/aufs/iml2=ro
/data/docker_lab/online/aufs/iml1=ro
64
65
66
67
/data/docker_lab/online/aufs/cn-l/.aufs.xino
# 追加一个内容试试,看看COW的反应
$ echo "write to mnt's iml1" >> ./mnt/iml3.txt
# 确实追加成功
$ cat ./mnt/iml3.txt
l3
write to mnt's iml1

# 但是 iml3 下的 iml3.txt 并没有增加内容
$ cat iml3/iml3.txt
l3

# 可以看到iml3.txt 是被复制到读写层进行了修改
$ cat cn-l/iml3.txt
l3
write to mnt's iml1
# 删除iml1.txt 看看UFS 的操作
$ rm ./mnt/iml1.txt
# 确实没有了
$ ls ./mnt/
cn-l.txt iml2.txt iml3.txt
# iml1镜像层的iml1.txt 还存在,下面看看读写层
$ ls iml1/iml1.txt
iml1/iml1.txt
# 可以看到出现很多 .wh开头的文件其中 .wh.iml1.txt 会被隐藏的文件,但不会实际去删除对应的read-only层文件,wh文件称为 whiteout 文件;
$ ll ./cn-l/ -a
drwxr-xr-x 4 root root 4096 9月 15 12:46 .
drwxr-xr-x 8 root root 4096 9月 14 01:21 ..
-rw-r--r-- 1 root root 14 9月 15 12:37 cn-l.txt
-rw-r--r-- 1 root root 23 9月 15 12:45 iml3.txt
-r--r--r-- 2 root root 0 9月 15 12:40 .wh.iml1.txt
-r--r--r-- 2 root root 0 9月 15 12:40 .wh..wh.aufs
drwx------ 2 root root 4096 9月 15 12:40 .wh..wh.orph
drwx------ 2 root root 4096 9月 15 12:40 .wh..wh.plnk

结语

  • 如果文章对您有帮助请 点赞,收藏;

参考

  • 推荐书籍: <<自己动手写docker>>

本文转载自: 掘金

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

让Eclipse起飞——这些插件不可不知 Devstyle

发表于 2020-12-02

Eclipse是很多Java开发者的第一个开发工具,尽管开源的Eclipse在一后起之秀的对比下,显得有些颓势,但是,Eclipse有着丰富的插件支持。选择合适的插件,Eclipse表示:老夫也能飞。

Devstyle

  • 🤞插件地址:Devstyle
  • ✨ 简介:
      这是一个流行的用于外观的Eclipse插件,它提升了eclipseide开发体验,你可以通过一系列其他特性来配置暗黑主题。而且支持自定义图标的主题。

在这里插入图片描述

Spring Tools

  • 🤞插件地址:Spring Tools
  • ✨ 简介:
      Spring Tools是你喜欢的编码环境的下一代Spring工具。 它从头进行了大规模重建,为开发基于Spring-Boot的企业应用程序提供了世界一流的支持。

在这里插入图片描述

SonarLint

  • 🤞插件地址:SonarLint
  • ✨ 简介:
      SonarLint是一个Eclipse插件,可向开发人员提示代码中的新错误和质量问题。支持Java, JavaScript, PHP, 和 Python。

在这里插入图片描述

EGit

  • 🤞插件地址:EGit
  • ✨ 简介:
      EGit是Eclipse的Git集成。 Git是一个分布式版本控制系统,这意味着每个开发人员都拥有该代码每个修订版本的所有历史记录的完整副本,从而对历史记录的查询非常快速且通用。

在这里插入图片描述

DBeaver

  • 🤞插件地址:DBeaver
  • ✨ 简介:
      DBeaver是面向开发人员和数据库管理员的免费通用SQL客户端/数据库工具。 它可以与任何具有JDBC驱动程序的数据库服务器一起使用。

它支持可插拔扩展。 版本3.x具有针对MySQL,Oracle,DB2,PostgreSQL,SQL Server,Sybase,Exasol,Informix,Teradata,Vertica,Netezza,Phoenix,Firebird,Derby,H2,WMI,Cassandra,MongoDB,Redis的特殊扩展。

在这里插入图片描述

JRebel for Eclipse

  • 🤞插件地址:JRebel for Eclipse
  • ✨ 简介:
      JRebel是一种生产力工具,它使开发人员可以立即重新加载代码更改。 它跳过了Java开发中常见的重建,重新启动和重新部署的周期。

JRebel使开发人员可以在相同的时间内完成更多工作,并保持流畅的编码体验。

JRebel支持大多数真实世界的企业Java堆栈,并且易于安装到现有的开发环境中。 你需要商业许可证才能使用Jebel。

在这里插入图片描述

Spotbugs

  • 🤞插件地址:Spotbugs
  • ✨ 简介:
      Spotbugs是FindBugs的精神继承者,是一种开源解决方案,它使用静态分析来搜索Java代码中的错误。 这个由社区维护的插件会检查你代码中的400多种不同的错误模式,包括空指针引用,无限递归循环,对Java库的错误使用和死锁。

在这里插入图片描述

Codota

  • 🤞插件地址:Codota
  • ✨ 简介:
      Codota是一项可由AI自动完成的服务工具,可帮助开发人员更智能,更快速地进行编码。 Codota使用AI将数百万个开源Java程序与你自己的代码上下文结合起来,并在你输入时提供代码建议。

在这里插入图片描述

CheckStyle

  • 🤞插件地址:CheckStyle
  • ✨ 简介:
      CheckStyle Eclipse插件是一种代码验证工具,可帮助你确保代码符合某些编码规范。 通过检查Java源代码并指出与已定义的编码规范集有所不同的项。

在这里插入图片描述

Quick JUnit

  • 🤞插件地址:Quick JUnit
  • ✨ 简介:
      JUnit是流行的Java开发开源单元测试框架,理所当然地获得了广泛的采用。 虽然JUnit本身已预装了Eclipse的面向Java的分支,但Quick JUnit插件使它可以通过舒适的快捷方式更快,更轻松地使用。

在这里插入图片描述

Eclipse Color Theme

  • 🤞插件地址:Eclipse Color Theme
  • ✨ 简介:
      提供不同风格的主题。Eclipse Color Theme 让开发人员可以方便地切换颜色主题,而且没有“副作用”。

在这里插入图片描述

CodeMix

  • 🤞插件地址:CodeMix
  • ✨ 简介:
      它包括Angular IDE、JSjet、实时预览、终端增强、Slack Integration、导航等等。支持ES6,能够进行JavaScript & TypeScript的调试。

在这里插入图片描述

Vaadin

  • 🤞插件地址:Vaadin
  • ✨ 简介:
      Vaadin是一个开放源Java UI库,用于创建丰富的Web用户界面。使用基于组件的API,开发人员可以使用纯Java创建出色的Web应用程序。

在这里插入图片描述

WindowBuilder

  • 🤞插件地址:WindowBuilder
  • ✨ 简介:
      WindowBuilder由SWT Designer和Swing Designer组成,使创建Java GUI应用程序非常容易,而无需花费大量时间编写代码。

在这里插入图片描述

PyDev

  • 🤞插件地址:PyDev
  • ✨ 简介:
      PyDev是使Eclipse可以用作Python IDE的插件。它使用高级类型推断技术,除了提供调试器,交互式控制台,重构,令牌浏览器,Django集成等之外,还可以提供诸如代码补全和代码分析之类的功能。

在这里插入图片描述

参考:

【1】:10 Eclipse plugins you shouldn’t code without
【2】:10 Best Eclipse IDE Plugins
【3】:14 Best (and Free) Plugins for Eclipse IDE in 2019
【4】:Eclipse marketplace popular
【5】:15 Productivity Tips for Eclipse Java IDE Users
【6】:Top 10 Essential Eclipse Plugins for Java Developers

本文转载自: 掘金

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

每个 Linux 新手都应该知道的 10 个命令

发表于 2020-12-01

你可能认为你是 Linux 新手,但实际上并不是。全球互联网用户有 3.74 亿,他们都以某种方式使用 Linux,因为 Linux 服务器占据了互联网的 90%。大多数现代路由器运行 Linux 或 Unix,TOP500 超级计算机 也依赖于 Linux。如果你拥有一台 Android 智能手机,那么你的操作系统就是由 Linux 内核构建的。

换句话说,Linux 无处不在。

但是使用基于 Linux 的技术和使用 Linux 本身是有区别的。如果你对 Linux 感兴趣,但是一直在使用 PC 或者 Mac 桌面,你可能想知道你需要知道什么才能使用 Linux 命令行接口(CLI),那么你来到了正确的地方。

下面是你需要知道的基本的 Linux 命令。每一个都很简单,也很容易记住。换句话说,你不必成为比尔盖茨就能理解它们。

1、 ls

你可能会想:“这是(is)什么东西?”不,那不是一个印刷错误 —— 我真的打算输入一个小写的 l。ls,或者说 “list”, 是你需要知道的使用 Linux CLI 的第一个命令。这个 list 命令在 Linux 终端中运行,以显示在存放在相应文件系统下的所有主要目录。例如,这个命令:

1
bash复制代码ls /applications

显示存储在 applications 文件夹下的每个文件夹,你将使用它来查看文件、文件夹和目录。

显示所有隐藏的文件都可以使用命令 ls -a。

2、 cd

这个命令是你用来跳转(或“更改”)到一个目录的。它指导你如何从一个文件夹导航到另一个文件夹。假设你位于 Downloads 文件夹中,但你想到名为 Gym Playlist 的文件夹中,简单地输入 cd Gym Playlist 将不起作用,因为 shell 不会识别它,并会报告你正在查找的文件夹不存在(LCTT 译注:这是因为目录名中有空格)。要跳转到那个文件夹,你需要包含一个反斜杠。改命令如下所示:

1
bash复制代码cd Gym\ Playlist

要从当前文件夹返回到上一个文件夹,你可以在该文件夹输入 cd ..。把这两个点想象成一个后退按钮。

3、 mv

该命令将文件从一个文件夹转移到另一个文件夹;mv 代表“移动”。你可以使用这个简单的命令,就像你把一个文件拖到 PC 上的一个文件夹一样。

例如,如果我想创建一个名为 testfile 的文件来演示所有基本的 Linux 命令,并且我想将它移动到我的 Documents 文件夹中,我将输入这个命令:

1
bash复制代码mv /home/sam/testfile /home/sam/Documents/

命令的第一部分(mv)说我想移动一个文件,第二部分(home/sam/testfile)表示我想移动的文件,第三部分(/home/sam/Documents/)表示我希望传输文件的位置。

4、 快捷键

好吧,这不止一个命令,但我忍不住把它们都包括进来。为什么?因为它们能节省时间并避免经历头痛。

  • CTRL+K 从光标处剪切文本直至本行结束
  • CTRL+Y 粘贴文本
  • CTRL+E 将光标移到本行的末尾
  • CTRL+A 将光标移动到本行的开头
  • ALT+F 跳转到下一个空格处
  • ALT+B 回到前一个空格处
  • ALT+Backspace 删除前一个词
  • CTRL+W 剪切光标前一个词
  • Shift+Insert 将文本粘贴到终端中
  • Ctrl+D 注销

这些命令在许多方面都能派上用场。例如,假设你在命令行文本中拼错了一个单词:

1
arduino复制代码sudo apt-get intall programname

你可能注意到 install 拼写错了,因此该命令无法工作。但是快捷键可以让你很容易回去修复它。如果我的光标在这一行的末尾,我可以按下两次 ALT+B 来将光标移动到下面用 ^ 符号标记的地方:

1
arduino复制代码sudo apt-get^intall programname

现在,我们可以快速地添加字母 s 来修复 install,十分简单!

5、 mkdir

这是你用来在 Linux 环境下创建目录或文件夹的命令。例如,如果你像我一样喜欢 DIY,你可以输入 mkdir DIY 为你的 DIY 项目创建一个目录。

6、 at

如果你想在特定时间运行 Linux 命令,你可以将 at 添加到语句中。语法是 at 后面跟着你希望命令运行的日期和时间,然后命令提示符变为 at>,这样你就可以输入在上面指定的时间运行的命令。

例如:

1
objectivec复制代码at 4:08 PM Satat> cowsay 'hello'at> CTRL+D

这将会在周六下午 4:08 运行 cowsay 程序。

7、 rmdir

这个命令允许你通过 Linux CLI 删除一个目录。例如:

1
arduino复制代码rmdir testdirectory

请记住,这个命令不会删除里面有文件的目录。这只在删除空目录时才起作用。

8、 rm

如果你想删除文件,rm 命令就是你想要的。它可以删除文件和目录。要删除一个文件,键入 rm testfile,或者删除一个目录和里面的文件,键入 rm -r。

9、 touch

touch 命令,也就是所谓的 “make file 的命令”,允许你使用 Linux CLI 创建新的、空的文件。很像 mkdir 创建目录,touch 会创建文件。例如,touch testfile 将会创建一个名为 testfile 的空文件。

10、 locate

这个命令是你在 Linux 系统中用来查找文件的命令。就像在 Windows 中搜索一样,如果你忘了存储文件的位置或它的名字,这是非常有用的。

例如,如果你有一个关于区块链用例的文档,但是你忘了标题,你可以输入 locate -blockchain 或者通过用星号分隔单词来查找 “blockchain use cases”,或者星号(*)。例如:

1
markdown复制代码locate -i*blockchain*use*cases*

还有很多其他有用的 Linux CLI 命令,比如 pkill 命令,如果你开始关机但是你意识到你并不想这么做,那么这条命令很棒。但是这里描述的 10 个简单而有用的命令是你开始使用 Linux 命令行所需的基本知识。

最后

感谢大家看到这里,文章有不足,欢迎大家指出;如果你觉得写得不错,那就给我一个赞吧。

也欢迎大家关注我的公众号:Java程序员聚集地,麦冬每天都会分享java相关技术文章或行业资讯,欢迎大家关注和转发文章!

本文转载自: 掘金

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

面试官问我 Object o = new Object()

发表于 2020-12-01

小小面试一下

前言蜜语

最近马师傅火的不要不要的,虽然没有抢到耗子尾汁的商标注册权,但是必须得蹭一波马师傅的热度,下面就是闪电五连鞭的教学环节,你准备好了吗!

在正式内容开始前先甩两篇关于类加载机制和内存布局的文章,因为今天的内容多少与这两篇文章有直接的联系,对这方面还比较薄弱的朋友可以先看看,地址我放在下面。

jvm┃java内存区域,跳槽大厂必会知识点!

moon不讲武德!!!一个类加载机制给面试官说蒙了!!

今天本文的内容就针对刚刚模拟面试两个问题

1.对象的创建过程

2.对象的内存布局

对象的创建过程

以下内容基于HotSpot VM 分代模型

这张图其实就能完整的说明一个对象的创建过程到底发生了什么,很多朋友可能一下看不懂,那么我们就跟着左上角的一步一步来:

  1. 一个对象new出来先判断线程栈是否能分配下
    • 如果能分配下,直接分配在栈中。
    • 如果分配不下则进行第二步。
  2. 判断该对象是否足够大
    • 如果足够大,则直接进入老年代。
    • 如果不够大,则进行第三步。
  3. 判断创建对象的线程的TLAB(本地线程缓冲区)空间是否足够
    • 如果足够,直接分配在TLAB中。
    • 如果不够,则进入Eden区中其他空间。然后进行第四步。
  4. GC清除
    • 如果清除掉了该对象,则直接结束。
    • 如果没有清除掉对象,进行第5步。
  5. 此刻对象进入Survivor 1 区,判断年龄是否足够大
    • 如果年龄足够大,则直接进入old区域。
    • 如果年龄不够大,则进入Survivor 2 区,然后进入第4步,循环往复。

通过这张流程图和步骤解析大家应该对一个对象的创建过程有一个很清晰的概念了,但是其中还是有很多小细节会被忽略,为什么jvm会在对象的创建过程中大作文章,会分这么多种情况?为了让大家更深入的能够理解它,我们就再来看看下面这几个问题:

  1. 为什么对象会选择先分配在栈中?

首先栈是线程私有的,将对象优先分配在栈中,可以通过pop直接将对象的所有信息,空间直接清除,当线程消亡的时候也可以直接清理这一块儿TLAB区域。

  1. 为什么jvm会让大对象会直接进入老年代?

大对象需要连续的空间来存储,如果不存入老年代对jvm说就可能是一个负担,如果没有足够的空间就有可能导致提前触发gc来清理空间来安置大对象。

  1. 为什么会选择先进入TLAB?

TLAB是线程本地缓冲区,TLAB的好处就是防止不同线程创建对象选择同一块儿内存区域而产生竞争,会使其概率大大减少。

  1. 为什么会有两个Survivor区?并且存活且年龄不够大的对象会从一个Survivor区转到另一个Survivor区?

根据根可达算法,jvm会从开始寻找到所有正在使用的对象,没有使用的就是垃圾,通常情况下,很多对象都是用完就抛弃的,所以真正在Survivor区长时间存活的对象非常少,将这部分对象从一个Survivor区转到另一个Survivor区后,就可以直接对这个Survivor区进行全量的空间回收了,效率会很高。

对象的内存布局

作者可不是标题党,哈哈,所以我们回到文章的标题,Object o = new Object();到底占用多少个字节?这道题的目的其实就是考验看你对对象的内存布局了解的是否清晰,先上图:

在java中对象的内存布局分为两种情况,非数组对象和数组对象,数组对象和非数组对象的区别就是需要额外的空间存储数组的长度length。

对象头

对象头又分为MarkWord和Class Pointer两部分。

  • MarkWord:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位,gc记录信息等等,在32位系统占4字节,在64位系统中占8字节。
  • ClassPointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节。
  • Length:只在数组对象中存在,用来记录数组的长度,占用4字节
Interface data
  • Interface data:对象实际数据,对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定。(这里不包括静态成员变量,因为其是在方法区维护的)
Padding
  • Padding:Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数,是因为当我们从磁盘中取一个数据时,不会说我想取一个字节就是一个字节,都是按照一块儿一块儿来取的,这一块大小是8个字节,所以为了完整,padding的作用就是补充字节,保证对象是8字节的整数倍。

moon在上文特意标注了32位系统和64位系统不同区域占用空间大小的区别,这是因为对象指针在64位JVM下的寻址更长,所以想比32位会多出来更多占用空间。

但是现在假设一个场景,公司现在项目部署的机器是32位的,你们老板要让你将项目迁移到64位的系统上,但是又因为64位系统比32位系统要多出更多占用空间,怎么办,因为正常来说我们是不需要这一部分多余空间的,所以jvm已经帮你考虑好了,那就是指针压缩。

指针压缩

-XX:+UseCompressedOops 这个参数就是JVM提供给你的解决方案,可以压缩指针,将占用的空间压缩为原来的一半,起到节约空间的作用,classpointer参数大小就受到其影响。

Object o = new Object()到底占用多少个字节?

通过刚才内存布局的学习后,这个问题就很好回答了,面试官其实就是想问你对象的内存布局是怎样的,我们这里就针对这个问题的结果分析下,这里分两种情况:

  • 在开启指针压缩的情况下,markword占用4字节,classpoint占用8字节,Interface data无数据,总共是12字节,由于对象需要为8的整数倍,Padding会补充4个字节,总共占用16字节的存储空间。
  • 在没有指针的情况下,markword占用8字节,classpoint占用8字节,Interface data无数据,总共是16字节。

结语

今天的文章和大家介绍了一个对象的创建过程,从它的出生到死亡,都经历了什么?也和大家详细的说明了对象的内存布局,深入解剖了一下对象的身体构造,这下面试官再问你,可就有的聊了,这篇文章的内容还是比较肝的,需要大家认真阅读一下,当然,也可以关注我的公众号’哪儿来的moon’,私下联系我,我是moon,下期见~

本文转载自: 掘金

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

深入理解《深入理解Java虚拟机》 《深入理解Java虚拟机

发表于 2020-11-30

《深入理解Java虚拟机》笔记

重读《深入理解Java虚拟机》,以问答的形式整理笔记。

Java内存区域是如何分配的?

Java在执行程序过程中,会将他所管理的内存划分为几个不同区域,有各自的用途,创建时间和销毁时间。
有这样几个区域:程序计数器、虚拟机栈、本地方法栈、堆、方法区、运行时常量池

程序计数器:一块比较小的内存空间,可以当作是当前线程所执行的字节码的行号的指示器。因为多线程下,是线程轮流切换,分配CPU的执行时间来实现的。一个内核,在任何一个确定的时刻,只能执行一条指令。所以为了线程来回切换后,还能继续从正确的位置执行指令,就需要用到程序计数器。同时,这块内存是每个线程私有的。
另外,如果线程执行的是一个Java方法,那计数器记录的是字节码指令的地址。如果执行的是一条本地方法,计数器值则为空。
这个区域是在《Java虚拟机规范》中唯一没有规定任何OOM情况的区域。

虚拟机栈:线程私有,生命周期与线程相同。每个方法执行,虚拟机都会创建一个栈帧,储存局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用到执行完毕,就对应着一个栈帧的入栈和出栈。

局部变量表:存储的是编译期可知的各种Java虚拟机基本数据类型,对象引用和 returnAddress 类型(指向一条字节码指令的地址)。
会抛出栈溢出异常和OOM异常。

本地方法栈:与虚拟机栈的作用非常类似,不同的是虚拟机栈为java方法服务,本地方法栈为本地方法(Native)服务。

堆:虚拟机管理的内存里最大的一块,被所有线程共享,在虚拟机启动的时候创建。堆唯一的目的就是存放对象实例,Java中几乎所有的对象实例都在堆分配内存。

方法区:用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区中还包括运行时常量池。Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与富豪引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

如何理解JMM?

Java内存模型:一种规范,规定了JVM如何使用计算机内存。
广义上来讲分为两个部分:JVM内存结构和JMM与线程规范

JMM主要是来控制Java之间的线程通信,决定一个线程对共享变量的写入何时对另一个线程可见(定义了线程和主内存之间的抽象关系)

JMM向开发者保证,如果程序是正确同步的,程序的执行将具有顺序一致性(顺序一致性内存模型)

保证顺序一致性的基础上(执行结果不变),给编译器和处理器最大的自由去优化(提高程序的并行度)。

手段:
内部(单线程下):happens-before原则
外部(多线程下):各种同步机制(volatile、锁、final、synchronize等)

这部分详细内容见另一篇文章:PRIK’s BLOG —— 深入理解Java内存模型

Java对象的创建过程是怎样的??

  1. 当虚拟机遇到一条字节码new指令时,先去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并且确定这个引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 类加载检查通过后,就要分配内存了。一个对象所需要的内存大小,在类加载完成后就可以确定。给对象分配内存相当于把一块完整内存快从Java堆中划分出来。这时候有两种情况:
    1. 如果堆内存是绝对完整的,那么只需要把一个指向已使用内存和未使用内存分界线的指针向一边挪一下就好了。这种分配方式成为指针碰撞。
    2. 如果堆内存不是完全整齐的,那就需要虚拟机维护一个列表,记录那块内存是可用的,有多大。分配内存时,需要在列表中找到一块足够大的内存空间划分给对象实例,并更新列表。这种方式成为空闲列表。
      选用哪种非配方式取决于堆内存是否整齐,堆内存是否整齐又取决于垃圾收集器是否有压缩整理的能力。Serial、ParNew等收集器带压缩整理过程,可以用指针碰撞的方式。CMS这种基于标记-清除算法的收集器时,理论上只能使用空闲列表的方式。
  3. 另外还需要考虑线程安全的问题。并发情况下,很多操作都是线程不安全的。解决方案有两种:
    1. CAS+失败重试,保证更新操作的原子性。
    2. 本地线程分配缓冲(Thread Local Allocation Buffer TLAB)。每个线程都有只属于自己的一小块内存,当这部分用完了,才同步锁定来分配内存。
  4. 然后需要将分配到的内存空间(除了对象头以外),初始化为零值(数据类型对应的零值),保证java对象的字段在代码中可以不赋初始值就可以使用。
  5. 设置对象头。将这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码(调用hashCode()方法时才会计算)、对象的GC分代年龄等信息,存放在对象头当中。
  6. 到这里从虚拟机的角度讲,一个对象已经创建完成了。从程序员的角度讲,对象创建才刚刚开始。因为构造函数还没有执行。这里还需要执行Class文件中的(),即构造函数,让对象按照我们的意愿构造好,一个真正可用的完整的对象才算创建完成。

Java对象由哪些部分组成?分别存储了什么信息?

Java对象由3部分组成,对象头,实例数据和对齐填充

  1. 对象头。对象头存储两部分信息,第一部分是对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。这部分数据长度为32bit或64bit(取决于虚拟机位数)。为了提高空间使用率,被设计为动态的数据结构。在极小的空间内存储尽量多的数据。根据对象状态不同,存储的信息意义也不同。第二部分是类型指针。就是对象指向它的类型元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  2. 实例数据。也就是我们真正存储的对象的信息,代码中定义的各种字段等。
  3. 对齐填充。占位符。HotSpot虚拟机的自动内存管理系统要求对象的大小都必须是8字节的整数倍。对象头已经被精确设计好是8bit的一倍或者两倍,对象实例数据部分如果不齐的话,需要对齐填充来补全。

虚拟机是如何找到Java对象的位置的?有几种方式?各自的优缺点是什么?

Java程序通过栈上的reference数据来操作堆上的具体对象。主流的方式有两种:句柄和直接指针。

句柄:java堆划分出一块内存来作为句柄池,reference中存储对象的句柄地址。句柄中存储了对象实例数据和类型数据各自的具体地址信息。
优势是:reference中存储稳定的句柄信息,类似垃圾收集一样需要移动对象的操作,只需要改变句柄中的数据指针,而reference本身不用被修改。

直接指针:reference中存储对象实例数据地址,而对象还需要考虑如何存放对象的类型数据相关的信息。
优势是:速度快,如果只访问对象本身的话,节省了一次指针定位的时间开销。

如何判断一个对象是需要被回收的垃圾?垃圾收集的过程大概是怎样的?

垃圾收集需要先回答三个问题:

  1. 哪些些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

判断哪些内存需要回收,就是在判断哪些对象已死(不需要了),主要有两种方法:

  1. 引用计数法:在对象中添加一个引用计数器,每当有一个地方引用时,计数器就加一。当引用失效时,计数器就减一。任何计数器为零的对象就是不会被再使用对象。虽然这个方法原理简单,效率也高,但没有被主流java虚拟机所采用。原因是这个看似简单的算法有很多例外的情况需要考虑,需要配合大量的额外处理才能保证正确工作。比如单纯的引用计数很难解决对象之间互相循环引用的问题。
  2. 可达性分析算法:基本思路是,通过一系列成为“GC Roots”的根对象作为起始节点,根据引用关系向下搜索,如果某个对象没有任何一条路能够达到GC Roots,那么就说明从GC Roots到这个对象不可达,依此证明这个对象不再被使用。

在可达性分析中被判定为不可达到对象,不会立即被垃圾收集。发现不可达会进行第一次标记,之后会再做一次筛选,条件是这个对象是否有必要执行finalize()方法。如果对象没有覆盖过该方法,或已经被虚拟机调用执行过,都被视为没有必要执行。有必要执行该方法的对象,会被放入一个队列中,之后由一条虚拟机自动建立的,低调度优先级的线程去执行他们的finalize()方法。虚拟机只保证触发这个方法开始执行,不承诺一定会等待他运行结束。另外,finalize()方法已被官方明确声明为不推荐使用。使用try-finally是更好的方法。

Java中都有哪些引用类型?

JDK1.2之后,Java将引用分为了强引用、软引用、弱引用和虚引用。

强引用:最传统的引用,简单讲就是new一个对象这种引用。无论任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收被引用的对象。

软引用:软引用比强引用弱一点,描述还有用,但不是必须的对象。如果发现内存不够用时,会先针对软引用对象进行二次回收。如果回收完之后还是没有足够的内存,才会抛出内存溢出异常。可以用来做内存敏感的缓存。SoftReference实现。

弱引用:比软引用更弱的引用。弱引用的对象只能存活到下一次垃圾收集发生。垃圾收集器开始工作时,无论当前内存是否充足,都会回收掉弱引用对象。也可以用来做内存敏感的且不太重要的缓存。WeakReference实现。

虚引用:也叫做幻影引用,是最弱的引用关系。一个对象是否有虚引用存在,不会对其生存时间构成任何影响,也无法通过一个虚引用来取得一个对象的实例。为一个对象设置虚引用的唯一目的只是为了能在这个对象被垃圾回收器回收之前,发送一条系统通知。PhantomReference实现。

方法区的垃圾回收是怎样的?

Java虚拟机规范提到可以不要求在方法区实现垃圾收集。而且方法区垃圾收集的性价比是比较低的,可回收的内存不多,而且判断什么该收集比较复杂。

方法区垃圾收集主要回收的是:废弃的常量和不再使用的类型。废弃常量与回收不再使用的对象比较类似。但判定一个类型是否废弃就比较麻烦了,需要同时满足三个条件:

  1. 该类所有的实例都被回收,java堆中不存在该类及任何派生子类的实例
  2. 加载该类的类加载器已经被回收
  3. 该类对应的java.lang.Classs对象没有在任何地方被引用,无法通过反射访问。
    但满足条件也只是允许回收,具体是否回收由参数控制。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP这类频繁自定义类加载器等场景中,通常需要java虚拟机具备类型卸载能力,以保证不会对方法区造成太大的内存压力。

什么是分代收集?

当前商业虚拟机的垃圾收集器,大都遵循了“分代收集”的理论进行设计。分代收集理论建立在两个假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

分代收集理论:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾收集的次数)分配到不同的区域中存储。

  1. 如果一个区域大多都是朝生夕灭的对象,那么这个区域回收时只需要关注少数可以存活下来的对象,而不是去标记很多需要回收的对象,这样就能以较低的代价回收大量的空间。
  2. 如果一个区域大多都是难以消亡的对象,那么就可以用较低的频率来收集这个区域,同时兼顾了垃圾收集的时间开销和内存空间的有效利用。

还有另一个问题:跨代引用。如果进行一次Minor GC,新生代的对象有可能被老年代引用,那么就还需要遍历整个老年代所有对象来确保可达性分析结果的正确性。这显然对性能影响很大。

我们可以从前两个假说推断出第三个假说:跨代引用对于同代引用来说只占极少数。因为存在互相引用的两个对象,应该是倾向于同时生存或者同时消亡的。比如一个老年代对象引用一个新生代对象,老年代对象难以消亡,新生代对象也不会消亡,随着年龄增长,也会晋升到老年代。

有了这个假说,我们只需要在新生代建立一个全局的数据结构(记忆集 Remembered Set),这个结构将老年代划分为若干小块,标示出哪一块内存会存在跨代引用。当发生Minor GC时,只需要将包含了跨代引用的小块内存中的老年代对象加入GC Roots进行可达性分析。

有哪些常见的垃圾收集算法?各自的原理是怎样的?

标记-清除算法
该算法氛围两个阶段:标记,清除。首先标记出所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象。也可以标记存活的对象,统一回收所有为标记的对象。标记过程就是对对象是否属于垃圾的判定的过程。
该算法有两个缺点:

  1. 执行效率不稳定。执行时间随着对象的数量增长而增长。
  2. 内存空间碎片化。标记清除之后会留下大量不连续的内存碎片。碎片空间太多会导致之后分配大内存对象的时候,因为找不到一块连续的足够大的内存,而不得不提前触发另一次垃圾收集动作。

标记-复制算法
半区复制算法:将可用内存华为大小相等的两块,每次只使用其中一块,一块用完了,就将还活着的对象复制到另一个块内存中,然后将这块内存全部清理掉。如果是老年代,会产生大量的复制对象的开销。如果是新生代,那就实现简单,运行高效。不过缺点很显然,就是内存利用率不高。
现在的商用虚拟机大都采用了这种方法的进化版:将新生代分为一块较大的Eden空间和两块较小的Survivor空间。每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,然后清理掉他们。HotSpot默认的大小比例时Eden:Survivor = 8 : 1。为了避免一些情况下,Survivor不足以容纳存活的对象,还会依赖其他区域内存(老年代)进行分配担保。

标记-整理算法
老年代一般不会选择标记复制算法。因为有大量的复制开销,还需要有额外的分配担保。针对老年代对象的存亡特征,标记-整理算法出现了:标记过程与标记-清除算法一样,但标记完成后,让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
但移动对象也是一个负担很重的操作,如果不移动,又会有碎片空间的问题,或者依赖更为复杂的内存分配器和内存访问器来解决。
一种解决办法是,平时大多是时候采用标记清除算法,知道内存空间碎片化程度太大,影响到大内存对象分配时,再进行一次标记-整理算法。

分代收集理论中,是如何解决跨代引用问题的?什么是记忆集?什么是卡表?卡表又是如何维护的?

为了解决跨代引用带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,来避免将整个老年代加入GC Roots扫描范围。

记忆集是一种记录从非收集区域指向收集区域的指针集合的数据结构。考虑到存储和维护成本,没必要将记忆集的精度精确到每一个指针。最终选择了卡精度:每个记录精确到一小块内存区域,该区域内有一个或一些对象含有跨代指针。这样的实现方式叫做卡表(Card Table)。底层数据结构为一个字节数组。每一个元素都对应着其表示的内存区域中一块特定大小的内存快。这个内存快叫做卡页。每个卡页中有多个对象,只要有一个对象含有跨代指针,就标记为1,其他为0。垃圾收集时,只要筛选出卡表中标记为1的元素,就能轻易找到那些卡页内存快包含跨代指针,把他们加入GC Roots中一起扫描即可。

那么卡表的状态又是如何维护的呢?

HotSpot虚拟机是通过写屏障技术维护卡表的。写屏障可以看作是虚拟机层面对“引用类型字段赋值”这个操作的AOP切面。在引用对象赋值时,产生一个环绕通知,可以利用这个特性来维护卡表。

可达性分析在并发的环境下是如何保证正确的?

在可达性分析时,必须在一致性快照的基础上对对象图进行遍历。否则会有可能导致将原本应该存活的对象标记为已消亡。
比如对一个被标记为死亡的对象A引用的对象B进行分析时,标记B为死亡,但之后B又被一个已经扫描过的,标记为存活的对象C引用它,这时不会重新再扫描这个存活的C对象,所以这个本应该存活的对象B就会被垃圾收集了。

要解决对象消失的问题,有两种方案。

增量更新:当被扫描过且标记为存活的对象插入新的指向被标记为死亡的对象的引用关系时,将这个引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的存活对象为根,重新扫描一次。

原始快照:一个被访问过,但还没有完全确定存活(不是所有引用都遍历了)的对象,如果赋值器要删除它引用的还没有被扫描到的对象的引用关系,就暂时记录下来,等扫描结束后,重新以该对象为根再扫描一次。

常见的垃圾收集器有哪些?都是如何工作的?

Serial收集器
最基础,最历史悠久的收集器,采用标记-复制算法。早期新生代收集器的唯一选择。单线程工作,而且当进行垃圾收集时,必须暂停其他所有工作线程,直到收集结束。
但他有简单高效的优点,而且是所有垃圾收集器中额外内存消耗最小的,是运行在客户端模式下的默认新生代收集器。另外对于单核处理器来说,单线程没有线程切换的开销,收集效率反而更高。它对于运行在客户端模式下(桌面应用)有着较好的应用。对于小内存的新生代来说,垃圾收集停顿时间完全可以控制在十几到几十毫秒。

Serial Old 收集器
Serial收集器的老年代版本。单线程。使用标记-整理算法。也是主要提供客户端模式下的虚拟机使用。在服务端也有使用:JDK5之前版本中搭配Parallel Scavenge收集器使用,还有就是作为CMS的备用收集器,并发收集发生Concurrent Mode Failure时使用。

ParNew收集器
Serial收集器的多线程版本,对于多核处理器来说,显然是要优于Serial收集器的。

Parallel Scavenge收集器
采用标记-复制算法的新生代收集器。多线程。关注的重点是达到一个可控制的吞吐量,又叫做吞吐量优先收集器。有参数可以设置为自动根据系统运行情况,设置合适的新生代大小、Eden与Survivor区域的比例、晋升老年代对象的大小等参数,来达到合适的停顿时间或者最大的吞吐量(自适应调节)。如果使用者对收集器手动优化存在困难,那么这个模式是一个不错的选择。

Parallel Od收集器
paralllel收集器的老年代版本。多线程。标记-整理算法。同样注重吞吐量。

CMS收集器
以最短回收停顿时间为目标,系统停顿时间尽量短来给用户最佳的交互体验。收集过程分为四个步骤:1. 初始标记 -> 2. 并发标记 -> 3. 重新标记 -> 4. 并发清除。初始标记和重新标记需要 Stop The World。初始标记只是标记一下GC Roots能直接关联到的对象,速度很快。并发标记是从GC Roots的直接关联对象开始遍历整个对象图的过程。重新标记是为了修正并发标记期间,用户线程继续运行导致的标记变动的一部分对象(增量更新),停顿时间稍长。最后是并发清除阶段。
但有三个明显的缺点:

  1. CMS收集器对处理器资源非常敏感。占用了一部分CPU计算能力,所以导致总吞吐量降低。
  2. 无法收集浮动垃圾有可能导致一次Full GC。并发标记和并发清除阶段,系统还是正常运行,所以需要预留出一部分内存来给系统使用。如果预留的内存无法满足程序新分配内存的需要,就会出现并发失败(Concurrent Mode Failure)。这时虚拟机会启用后备方案,冻结用户线程,临时启用Serial Old收集器来重新进行老年代的垃圾收集。这样会停顿更长的时间。
  3. 因为是标记-清除算法,收集结束时会产生大量碎片空间。有时会提前出发Full GC。

Garbage First收集器
G1收集器。里程碑。开创了面向局部收集的思路和基于Region的内存布局形式。在延迟可控的情况下,获得尽可能高的吞吐量。
G1收集器将连续的Java堆划分为多个大小相等的独立区域,每一个Region都可以根据需要扮演新生代的Eden空间,Survivor空间或者老年空间。还有一类Humongous区域,用来存储大对象,基本等同于老年代。
G1收集器会跟踪各个Region中垃圾的价值大小,即回收所获得的空间大小和回收所需要的时间。会根据价值维护一个优先级列表,每次根据用户设定的允许收集停顿时间,来优先回收价值最大的Region。保证了G1在有限的时间内获得尽可能高的收集效率。
每个Region会维护自己的记忆集,来解决跨Region引用问题。因此会占用更多的内存(堆内存的10%~20%)。
与CMS采用增量更新算法实现并发收集不同,G1采用原始快照算法实现。
收集过程:

  • 初始标记: 标记GC Roots能够直接关联到的对象
  • 并发标记 :并发进行可达性分析
  • 最终标记 :短暂停顿,处理并发标记结束时遗留的少量对象(原始快照)
  • 筛选回收:更新Region统计数据,根据价值和回收成本机型排序,并依据用户期望的停顿时间来制定回收计划。选择任意多个Region构成回收集,将存活的对象复制到空的Region中,在清除掉整个旧的Region空间。移动对象的过程必须暂停用户线程。并且由多条收集器线程并发执行。

G1对比CMS
根据经验,6-8G以下CMS更优,以上G1更优。未来G1会逐步甩开CMS。G1的内存占用和处理器负载都要高于CMS。而且现在也无法完全替代CMS的存在。

收集器对比

收集器 并行 适用区域 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU下Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU下Client模式/CMS后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多CPU下Server模式与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算,但交互比较少
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算,但交互比较少
CMS 并发 老年代 标记-清楚 响应速度优先 集中在互联网网站或B/S系统服务端的Java应用中
G1 并发 both 标记-整理+复制 响应速度优先 面向服务端应用,替换CMS

常用的收集器组合:

  1. Serial + Serial Old 实现单线程的低延迟垃圾回收
  2. ParNew + CMS 实现多线程的低延迟垃圾回收
  3. Parallel Scavenge + Parallel Scavenge Old 实现多线程高吞吐量垃圾回收

如何选择合适的垃圾收集器

需要根据实际情况多尝试,指导性原则是:

  1. 如果系统考虑吞吐量优先,CPU资源都用来最大程度处理业务,用 Parallel GC
  2. 如果系统考虑低延迟,每次GC时间尽量短,用 CMS GC
  3. 如果系统堆内存较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC

对于内存的考虑:

  1. 4G以上,用 G1 GC 性价比比较高
  2. 如果超过 8G,达到了 16-64G 内存,那么非常推荐使用G1 GC

各版本JDK默认垃圾收集器是什么?

java8以前是 Parallel GC,Java9以后改为 G1 GC。

类加载的过程是怎样的?每个阶段都做了什么?

类加载会经历:加载、验证、准备、解析和初始化五个阶段。

1. 加载

  1. 通过一个类的权限定名来获取定义此类的二进制字节流。(花样很多,加密,网络获取,计算生成,数据库读取)如果找不到会抛出NoClassDefFoundError
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表着个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

2. 验证

  1. 文件格式。是否魔数0xCAFEBABE开头,版本号虚拟机是否适配等
  2. 元数据。是否符合Java语言规范
  3. 字节码。语义是否合法
  4. 符号引用。是否缺少或禁止访问需要的外部类、方法等。
    (VerifyError、ClassFormatError、UnsupportedClassVersionError)

3. 准备
为类中定义的变量(静态变量)分配内存,设置初始值。

4. 解析
将常量池内的符号引用替换为直接引用。

5. 初始化
执行类构造器()方法。由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生。(注意按顺序收集,静态语句块中语句无法访问定义在语句块之后的变量)
JVM明确规定,必须在类的首次主动使用时才能执行类的初始化

什么是双亲委派模型?Java中的类加载器都有哪几种?各自的作用是什么?

双亲委派模型的工作过程是:如果一个类加载器收到了加载类的请求,不会先自己尝试加载,而是先委派给父类加载器去加载。所以所有的类加载请求都会被传送到最顶层的类加载器中。只有当父加载器无法完成这个加载请求(没找到),才会让子加载器去尝试完成。(ClassNotFundException)

这样做的好处是类具有了一种优先层级关系,比如Java中的Object类,只会由最顶端的启动类加载器加载。开发人员无法自己新写一个Object类来替代它,从一方米娜也保证了程序的安全性。

从虚拟机的角度来看,只有两种不同的类加载器:启动类加载器和其他类加载器。

启动类加载器:由C++实现,是虚拟机自身的一部分。负责加载存放在<HAVA_HOME>/lib目录下,或者被-Xbootclasspath参数所制定的路径中存放的,java虚拟机能够识别的(按照名字识别,如rt.jar,名字不符的不会加载)类。无法被java程序直接引用。如果自定义类加载器时,需要委派给启动类加载器,直接使用null代替即可。

扩展类加载器:负责加载<JAVA_HOME>/lib/ext目录中的类。或悲java.ext.dirs系统变量所制定的路径中的所有类库。

应用程序类加载器:负责加载classpath上的所有类库。可以通过ClassLoader.getSystemClassLoader()来获取应用类加载器。如果没有使用自定义类加载器,用户自定义的类都由此加载。

自定义类加载器

虚拟机是如何做锁优化的?都有哪些类型的锁?

适应性自旋
自旋虽然避免了线程切换的开销,但如果自旋时间过长,会白白占用处理器资源,带来性能的浪费。JDK6对自旋锁进行了优化,引入了自适应性自旋。自适应意味着自旋时间不再是固定的时间,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定的。如果在同一个锁上,上一个线程刚刚自旋成功获得了锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次自旋很少的时间就获得锁,允许自旋一个相对较长的时间。如果某个锁自旋很少成功,则之后要获取这个锁时有可能直接忽略掉自旋的过程。

锁消除
虚拟机即使编译器在运行时,对一些代码要求同步,但是被检测到不可能存在数据竞争的锁进行消除。主要判定依据是逃逸分析。

锁粗化
原则上,写代码时应该讲同步块的作用范围限制得尽量小。让需要同步的操作数尽量少,即使存在竞争,也能让等待的线程尽快拿到锁。但如果一系列连续操作都对同一个对象反复加锁解锁,甚至加锁操作在循环体之中,即使没有线程竞争,频繁地进行互斥同步操作也是会导致不必要的性能损耗。所以虚拟机会在这种情况下将锁的范围变大,比如循环体内的上锁操作移动到循环体外。

轻量级锁
利用对象头的 Mark Word 实现。

为了节省空间,Mark Word 在对象处于不同状态时,会存储不一样的信息。比如哈希码,GC 分代年龄等。在对象为被锁定是,有2个 bit 存储锁标志位,1个 bit 为0,表示未进入偏向模式。

当代码即将进入同步块时,如果该同步对象没有被锁定(锁标志为01),虚拟机将在当前线程的栈帧中建立一个叫 Lock Record 的空间,存储对象当前 Mark Word 的拷贝。
然后使用CAS将 Mark Word 更新为指向 Lock Record 的指针。
如果更新成功,表示该线程拥有了这个对象的锁,并将 Mark Word 中锁标志位改为“00”,表示处于轻量级锁定状态。

如果更新失败,那表示已经有别的线程获得了锁。当前线程进入自旋,继续尝试获取轻量级锁。如果一定时间之后任没有获取到,则将轻量级锁膨胀为重量级锁(修改对象头信息为重量级锁:指向重量级锁的指针+标志位10),并挂起等待。

轻量级锁释放时,也需要 CAS,将保存的 Mark Word 更新回来。如果更新成功,则同步顺利完成。如果更新失败,则表示上一步有别的线程也想要获取锁,将锁膨胀为重量级锁。所以需要在释放锁的同时,唤醒被挂起的线程。

需要注意的是,轻量级锁能够提升性能的依据是:“对于绝大部锁来说,整个同步周期内都是不存在竞争的”这一经验。通过 CAS 避免了使用互斥量的开销。如果大多数时候都存在锁竞争,那么除了原本就需要的互斥量开销外,还要多出 CAS 操作的开销,反而开销更大了。

偏向锁
目的:消除无竞争情况下的同步原语,来提高性能。
如果一个线程获取到了一个偏向锁,在没有别的线程竞争的情况下,持有偏向锁的这个线程永远都不需要再同步。
一旦有另一个线程去尝试获取这个锁,则偏向模式马上结束。如果对象未锁定,则撤销偏向(偏向位设为0),恢复到未锁定(标志位01)。如果对象已锁定,则撤销偏向并转为轻量级锁(标志位00)。
如果大多数锁都是被不同的多个线程访问,那么偏向模式实际上是多余的。可以通过参数关闭偏向锁。

什么是自旋锁?什么是适应性自旋?

自旋锁
大多数情况下,共享数据的锁定状态不会持续很久,如果某个线程请求某个锁失败,为了这个很短的时间去挂起/恢复线程不值得。可以让这个线程不要放弃处理器执行时间,执行一个忙循环(也就是自旋),来等待一会前边持有锁的线程。如果自旋一个固定时间之后还没有等到锁,就挂起线程。

适应性自旋
自旋虽然避免了线程切换的开销,但如果自旋时间过长,会白白占用处理器资源,带来性能的浪费。JDK6对自旋锁进行了优化,引入了自适应性自旋。自适应意味着自旋时间不再是固定的时间,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定的。如果在同一个锁上,上一个线程刚刚自旋成功获得了锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次自旋很少的时间就获得锁,允许自旋一个相对较长的时间。如果某个锁自旋很少成功,则之后要获取这个锁时有可能直接忽略掉自旋的过程。

什么是逃逸分析?有什么用?

逃逸分析的最基本原理是:分析对象动态作用域,当一个对象在方法里被定义后,可能被外部方法引用,例如作为调用参数传递到其他方法中,这种称谓方法逃逸。还有可能被外部线程访问,这种称谓线程逃逸。不逃逸,方法逃逸,线程逃逸,称谓对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法外或线程外(也就是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法,不逃逸出线程),就可以采取不同程度的优化:

  1. 栈上分配:在堆上分配内存,对于回收和整理来说,都是一个耗费资源的操作。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存是一个很不错的主意。对象占用的内存空间就可以随着栈帧出栈而销毁。这样垃圾收集器的压力就会少很多。
  2. 标量替换:若一个数据已经无法再分解为更小的数据来展示了,比如java虚拟机中的原始数据类型,引用类型等,这样的数据被称为标量。而java对象是标量的反义词,聚合量。根据程序访问的 情况,将启用到的成员变量,恢复为原始类型来访问,这个过程叫做标量替换。如果逃逸分析证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候可能不回去创建这个对象,而改为直接创建他的若干被这个方法使用的成员变量来替代。可以为后续进一步优化创造条件。
  3. 同步消除:线程同步是一个比较重的操作。如果逃逸分析证明一个变量不会逃逸出线程,那么这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

关注我的博客

trzoey.github.io/blog-prik/

本文转载自: 掘金

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

彻底搞懂 IO 底层原理 一、混乱的 IO 概念 二、用户空

发表于 2020-11-30

武侠小说里有很多的“心法”和“招式”。计算机技术里的“心法”和“招式”呢,我们可以简称为“道”和“术”;

“道” 最基础的计算机理论,隐藏于表象之下,非常抽象、晦涩难懂,需要用具象化的事物加以理解;

“术” 具体的技艺,它有可能是一门语言,比如:python 出手见效快;

我们今天要给大家讲的底层的IO就属于“道”的范畴,看上去简单,实则抽象。并且在它之上衍生出了语言层面用于实战的技术,比如我们熟悉的java语言中的NIO或者像Netty这样的框架。

一、混乱的 IO 概念

IO是Input和Output的缩写,即输入和输出。广义上的围绕计算机的输入输出有很多:鼠标、键盘、扫描仪等等。而我们今天要探讨的是在计算机里面,主要是作用在内存、网卡、硬盘等硬件设备上的输入输出操作。

谈起IO的模型,大多数人脑子里肯定是一坨混乱的概念,“阻塞”、“非阻塞”,“同步”、“异步”有什么区别?很多同学傻傻分不清,有尝试去搜索相关资料去探究真相,结果又被淹没在茫茫的概念之中。

这里尝试简单地去解释下为啥会出现这种现象,其中一个很重要的原因就是大家看到的资料对概念的解释都站在了不同的角度,有的站在了底层内核的视角,有的直接在java层面或者Netty框架层面给大家介绍API,所以给大家造成了一定程度的困扰。

所以在开篇之前,还是要说下本文所站的视角,我们将会从底层内核的层面给大家讲解下IO。因为万变不离其宗,只有了解了底层原理,不管语言层面如何花里胡哨,我们都能以不变应万变。

二、用户空间和内核空间

为了便于大家理解复杂的IO以及零拷贝相关的技术,我们还是得花点时间在回顾下操作系统相关的知识。这一节我们重点看下用户空间和内核空间,基于此后面我们才能更好地聊聊多路复用和零拷贝。

硬 件 层(Hardware)

包括和我们熟知的和IO相关的CPU、内存、磁盘和网卡几个硬件;

内核空间(Kernel Space)

计算机开机后首先会运行内核程序,内核程序占用的一块私有的空间就是内核空间,并且可支持访问CPU所有的指令集(ring0 - ring3)以及所有的内存空间、IO及硬件设备;

用户空间(User Space)

每个普通的用户进程都有一个单独的用户空间,用户空间只能访问受限的资源(CPU的“保护模式”)也就是说用户空间是无法直接操作像内存、网卡和磁盘等硬件的;

如上所述,那我们可能会有疑问,用户空间的进程想要去访问或操作磁盘和网卡该怎么办呢?

为此,操作系统在内核中开辟了一块唯一且合法的调用入口“System Call Interface”,也就是我们常说的系统调用,系统调用为上层用户提供了一组能够操作底层硬件的API。这样一来,用户进程就可以通过系统调用访问到操作系统内核,进而就能够间接地完成对底层硬件的操作。这个访问的过程也即用户态到内核态的切换。常见的系统调用有很多,比如:内存映射mmap()、文件操作类的open()、IO读写read()、write()等等。

三、IO模型

1、 BIO(Blocking IO)

我们先看一下大家都熟悉的BIO模型的 Java 伪代码:

1
2
3
4
5
6
7
8
9
10
11
java复制代码ServerSocket serverSocket = new ServerSocket(8080);        // step1: 创建一个ServerSocket,并监听8080端口
while(true) { // step2: 主线程进入死循环
Socket socket = serverSocket.accept(); // step3: 线程阻塞,开启监听

BufferedReader reader = new BufferedReader(nwe InputStreamReader(socket.getInputStream()));
System.out.println("read data: " + reader.readLine()); // step4: 数据读取


PrintWriter print = new PrintWriter(socket.getOutputStream(), true);
print.println("write data"); // step5: socket数据写入
}

)这段代码可以简单理解成一下几个步骤:

  • 创建ServerSocket,并监听8080端口;
  • 主线程进入死循环,用来阻塞监听客户端的连接,socket.accept();
  • 数据读取,socket.read();
  • 写入数据,socket.write();

问题

以上三个步骤:accept(…)、read(…)、write(…)都会造成线程阻塞。上述这个代码使用了单线程,会导致主线程会直接夯死在阻塞的地方。

优化

我们要知道一点“进程的阻塞是不会消耗CPU资源的”,所以在多核的环境下,我们可以创建多线程,把接收到的请求抛给多线程去处理,这样就有效地利用了计算机的多核资源。甚至为了避免创建大量的线程处理请求,我们还可以进一步做优化,创建一个线程池,利用池化技术,对暂时处理不了的请求做一个缓冲。

2、“C10K”问题

“C10K”即“client 10k”用来指代数量庞大的客户端;

BIO看上去非常的简单,事实上采用“BIO+线程池”来处理少量的并发请求还是比较合适的,也是最优的。但是面临数量庞大的客户端和请求,这时候使用多线程的弊端就逐渐凸显出来了:

  • 严重依赖线程,线程还是比较耗系统资源的(一个线程大约占用1M的空间);
  • 频繁地创建和销毁代价很大,因为涉及到复杂的系统调用;
  • 线程间上下文切换的成本很高,因为发生线程切换前,需要保留上一个任务的状态,以便切回来的时候,可以再次加载这个任务的状态。如果线程数量庞大,会造成线程做上下文切换的时间甚至大于线程执行的时间,CPU负载变高。

3、NIO非阻塞模型

下面开始真正走向Java NIO或者Netty框架所描述的“非阻塞”,NIO叫Non-Blocking IO或者New IO,由于BIO可能会引入的大量线程,所以可以简单地理解NIO处理问题的方式是通过单线程或者少量线程达到处理大量客户端请求的目的。为了达成这个目的,首先要做的就是把阻塞的过程非阻塞化。要想做到非阻塞,那必须得要有内核的支持,同时需要对用户空间的进程暴露系统调用函数。所以,这里的“非阻塞”可以理解成系统调用API级别的,而真正底层的IO操作都是阻塞的,我们后面会慢慢介绍。

事实上,内核已经对“非阻塞”做好了支持,举个我们刚刚说的的accept()方法阻塞的例子(Tips:java中的accept方法对应的系统调用函数也叫accept),看下官方文档对其非阻塞部分的描述。

官方文档对accetp()系统调用的描述是通过把”flags“参数设成”SOCK_NONBLOCK“就可以达到非阻塞的目的,非阻塞之后线程会一直处理轮询调用,这时候可以通过每次返回特殊的异常码“EAGAIN”或”EWOULDBLOCK“告诉主程序还没有连接到达可以继续轮询。

我们可以很容易想象程序非阻塞之后的一个大致过程。所以,非阻塞模式有个最大的特点就是:用户进程需要不断去主动询问内核数据准备好了没有!

下面我们通过一段伪代码,看下这个调用过程:

1
2
3
4
5
6
7
8
9
10
11
scss复制代码// 循环遍历
while(1) {
// 遍历fd集合
for (fdx in range(fd1, fdn)) {
// 如果fdx有数据
if (null != fdx.data) {
// 进行读取和处理
read(fdx)&handle(fdx);
}
}
}

)这种调用方式也暴露出非阻塞模式的最大的弊端,就是需要让用户进程不断切换到内核态,对连接状态或读写数据做轮询。有没有一种方式来简化用户空间for循环轮询的过程呢?那就是我们下面要重点介绍的IO多路复用模型。

4、IO多路复用模型

非阻塞模型会让用户进程一直轮询调用系统函数,频繁地做内核态切换。想要做优化其实也比较简单,我们假想个业务场景,A业务系统会调用B的基础服务查询单个用户的信息。随着业务的发展,A的逻辑变复杂了,需要查100个用户的信息。很明显,A希望B提供一个批量查询的接口,用集合作为入参,一次性把数据传递过去就省去了频繁的系统间调用。

多路复用实际也差不多就是这个实现思路,只不过入参这个“集合”需要你注册/填写感兴趣的事件,读fd、写fd或者连接状态的fd等,然后交给内核帮你进行处理。

那我们就具体来看看多路复用里面大家都可能听过的几个系统调用 - select()、poll()、epoll()。

4.1 select()

select() 构造函数信息如下所示:

1
2
3
4
5
6
7
8
9
10
11
markdown复制代码/**
* select()系统调用
*
* 参数列表:
* nfds - 值为最大的文件描述符+1
* *readfds - 用户检查可读性
* *writefds - 用户检查可写性
* *exceptfds - 用于检查外带数据
* *timeout - 超时时间的结构体指针
*/
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

)“)官方文档对**select()**的描述:

DESCRIPTION

select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become “ready” for some class of I/O operation (e.g.,input possible). A file descriptor is considered ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.

select()允许程序监控多个fd,阻塞等待直到一个或多个fd到达”就绪”状态。

内核使用select()为用户进程提供了类似批量的接口,函数本身也会一直阻塞直到有fd为就绪状态返回。下面我们来具体看下select()函数实现,以便我们更好地分析它有哪些优缺点。在select()函数的构造器里,我们很容易看到”fd_set“这个入参类型。它是用位图算法bitmap实现的,使用了一个大小固定的数组(fd_set设置了FD_SETSIZE固定长度为1024),数组中的每个元素都是0和1这样的二进制byte,0,1映射fd对应位置上是否有读写事件,举例:如果fd == 5,那么fd_set = 000001000。

同时 fd_set 定义了四个宏来处理bitmap:

  • FD_ZERO(&set); // 初始化,清空的作用,使集合中不含任何fd
  • FD_SET(fd, &set); // 将fd加入set集合,给某个位置赋值的操作
  • FD_CLR(fd, &set); // 将fd从set集合中清除,去掉某个位置的值
  • FD_ISSET(fd, &set); // 校验某位置的fd是否在集合中

使用bitmap算法的好处非常明显,运算效率高,占用内存少(使用了一个byte,8bit)。我们用伪代码和图片来描述下用户进程调用select()的过程:

假设fds为{1, 2, 3, 5, 7}对应的bitmap为”01110101”,抛给内核空间轮询,当有读写事件时重新标记同时停止阻塞,然后整体返回用户空间。由此我们可以看到select()系统调用的弊端也是比较明显的:

  • 复杂度O(n),轮询的任务交给了内核来做,复杂度并没有变化,数据取出后也需要轮询哪个fd上发生了变动;
  • 用户态还是需要不断切换到内核态,直到所有的fds数据读取结束,整体开销依然很大;
  • fd_set有大小的限制,目前被硬编码成了1024;
  • fd_set不可重用,每次操作完都必须重置;

4.2 poll()

poll() 构造函数信息如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
arduino复制代码/**
* poll()系统调用
*
* 参数列表:
* *fds - pollfd结构体
* nfds - 要监视的描述符的数量
* timeout - 等待时间
*/
int poll(struct pollfd *fds, nfds_t nfds, int *timeout);


### pollfd的结构体
struct pollfd{
 int fd;// 文件描述符
 short event;// 请求的事件
 short revent;// 返回的事件
}

)“)官方文档对**poll()**的描述:

DESCRIPTION

poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.

poll() 非常像select(),它也是阻塞等待直到一个或多个fd到达”就绪”状态。

看官方文档描述可以知道,poll()和select()是非常相似的,唯一的区别在于poll()摒弃掉了位图算法,使用自定义的结构体pollfd,在pollfd内部封装了fd,并通过event变量注册感兴趣的可读可写事件(POLLIN、POLLOUT),最后把 pollfd 交给内核。当有读写事件触发的时候,我们可以通过轮询 pollfd,判断revent确定该fd是否发生了可读可写事件。

老样子我们用伪代码来描述下用户进程调用 poll() 的过程:

poll() 相对于select(),主要的优势是使用了pollfd的结构体:

  • 没有了bitmap大小1024的限制;
  • 通过结构体中的revents置位;

但是用户态到内核态切换及O(n)复杂度的问题依旧存在。

4.3 epoll()

epoll()应该是目前最主流,使用范围最广的一组多路复用的函数调用,像我们熟知的Nginx、Redis都广泛地使用了此种模式。接下来我们重点分析下,epoll()的实现采用了“三步走”策略,它们分别是epoll_create()、epoll_ctl()、epoll_wait()。

4.3.1 epoll_create()

1
2
3
4
arduino复制代码/**
* 返回专用的文件描述符
*/
int epoll_create(int size);

)用户进程通过 epoll_create() 函数在内核空间里面创建了一块空间(为了便于理解,可以想象成创建了一块白板),并返回了描述此空间的fd。

4.3.2 epoll_ctl()

1
2
3
4
5
6
7
8
9
10
csharp复制代码/**
* epoll_ctl()系统调用
*
* 参数列表:
* epfd - 由epoll_create()返回的epoll专用的文件描述符
* op - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
* fd - 关联的文件描述符
* event - 指向epoll_event的指针
*/
int epoll_ctl(int epfd, int op, int fd , struce epoll_event *event );

)“)刚刚我们说通过epoll_create()可以创建一块具体的空间“白板”,那么通过epoll_ctl() 我们可以通过自定义的epoll_event结构体在这块”白板上”注册感兴趣的事件了。

  • 注册 - EPOLL_CTL_ADD
  • 修改 - EPOLL_CTL_MOD
  • 删除 - EPOLL_CTL_DEL

4.3.3 epoll_wait()

1
2
3
4
5
6
7
8
9
10
csharp复制代码/**
* epoll_wait()返回n个可读可写的fds
*
* 参数列表:
* epfd - 由epoll_create()返回的epoll专用的文件描述符
* epoll_event - 要进行的操作例如注册事件,可能的取值:注册-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、删除-EPOLL_CTL_DEL
* maxevents - 每次能处理的事件数
* timeout - 等待I/O事件发生的超时值;-1相当于阻塞,0相当于非阻塞。一般用-1即可
*/
int epoll_wait(int epfd, struce epoll_event *event , int maxevents, int timeout);

epoll_wait() 会一直阻塞等待,直到硬盘、网卡等硬件设备数据准备完成后发起硬中断,中断CPU,CPU会立即执行数据拷贝工作,数据从磁盘缓冲传输到内核缓冲,同时将准备完成的fd放到就绪队列中供用户态进行读取。用户态阻塞停止,接收到具体数量的可读写的fds,返回用户态进行数据处理。

整体过程可以通过下面的伪代码和图示进一步了解:

epoll() 基本上完美地解决了 poll() 函数遗留的两个问题:

  • 没有了频繁的用户态到内核态的切换;
  • O(1)复杂度,返回的”nfds”是一个确定的可读写的数量,相比于之前循环n次来确认,复杂度降低了不少;

四、同步、异步

细心的朋友可能会发现,本篇文章一直在解释“阻塞”和“非阻塞”,“同步”、“异步”的概念没有涉及,其实在很多场景下同步&异步和阻塞&非阻塞基本上是一个同义词。阻塞和非阻塞适合从系统调用API层面来看,就像我们本文介绍的select()、poll()这样的系统调用,同步和异步更适合站在应用程序的角度来看。应用程序在同步执行代码片段的时候结果不会立即返回,这时候底层IO操作不一定是阻塞的,也完全有可能是非阻塞。所以说:

  • **阻塞和非阻塞:**读写没有就绪或者读写没有完成,函数是否要一直等待还是采用轮询;
  • **同步和异步:**同步是读写由应用程序完成。异步是读写由操作系统来完成,并通过回调的机制通知应用程序。

这边顺便提两种大家可能会经常听到的模式:Reactor和Preactor。

  • Reactor 模式:主动模式。
  • Preactor 模式:被动模式。

五、总结

本篇文章从底层讲解了下从BIO到NIO的一个过程,着重介绍了IO多路复用的几个系统调用select()、poll()、epoll(),分析了下各自的优劣,技术都是持续发展演进的,目前也有很多的痛点。后续会继续给大家介绍下与此相关的“零拷贝”技术,以及Java NIO和Netty框架。

vivo 官网商城开发团队

本文转载自: 掘金

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

求你了,不要再在对外接口中使用枚举类型了!

发表于 2020-11-30

GitHub 18k Star 的Java工程师成神之路,不来了解一下吗!

GitHub 18k Star 的Java工程师成神之路,真的不来了解一下吗!

GitHub 18k Star 的Java工程师成神之路,真的真的不来了解一下吗!

最近,我们的线上环境出现了一个问题,线上代码在执行过程中抛出了一个IllegalArgumentException,分析堆栈后,发现最根本的的异常是以下内容:

1
2
yaml复制代码java.lang.IllegalArgumentException: 
No enum constant com.a.b.f.m.a.c.AType.P_M

大概就是以上的内容,看起来还是很简单的,提示的错误信息就是在AType这个枚举类中没有找到P_M这个枚举项。

于是经过排查,我们发现,在线上开始有这个异常之前,该应用依赖的一个下游系统有发布,而发布过程中是一个API包发生了变化,主要变化内容是在一个RPC接口的Response返回值类中的一个枚举参数AType中增加了P_M这个枚举项。

但是下游系统发布时,并未通知到我们负责的这个系统进行升级,所以就报错了。

我们来分析下为什么会发生这样的情况。

问题重现

首先,下游系统A提供了一个二方库的某一个接口的返回值中有一个参数类型是枚举类型。

一方库指的是本项目中的依赖 二方库指的是公司内部其他项目提供的依赖 三方库指的是其他组织、公司等来自第三方的依赖

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

public AResponse doSth(ARequest aRequest);
}

public Class AResponse{

private Boolean success;

private AType aType;
}

public enum AType{

P_T,

A_B
}

然后B系统依赖了这个二方库,并且会通过RPC远程调用的方式调用AFacadeService的doSth方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码public class BService {

@Autowired
AFacadeService aFacadeService;

public void doSth(){
ARequest aRequest = new ARequest();

AResponse aResponse = aFacadeService.doSth(aRequest);

AType aType = aResponse.getAType();
}
}

这时候,如果A和B系统依赖的都是同一个二方库的话,两者使用到的枚举AType会是同一个类,里面的枚举项也都是一致的,这种情况不会有什么问题。

但是,如果有一天,这个二方库做了升级,在AType这个枚举类中增加了一个新的枚举项P_M,这时候只有系统A做了升级,但是系统B并没有做升级。

那么A系统依赖的的AType就是这样的:

1
2
3
4
5
6
7
8
markdown复制代码public enum AType{

P_T,

A_B,

P_M
}

而B系统依赖的AType则是这样的:

1
2
3
4
5
6
arduino复制代码public enum AType{

P_T,

A_B
}

这种情况下**,在B系统通过RPC调用A系统的时候,如果A系统返回的AResponse中的aType的类型位新增的P_M时候,B系统就会无法解析。一般在这种时候,RPC框架就会发生反序列化异常。导致程序被中断。**

原理分析

这个问题的现象我们分析清楚了,那么再来看下原理是怎样的,为什么出现这样的异常呢。

其实这个原理也不难,这类RPC框架大多数会采用JSON的格式进行数据传输,也就是客户端会将返回值序列化成JSON字符串,而服务端会再将JSON字符串反序列化成一个Java对象。

而JSON在反序列化的过程中,对于一个枚举类型,会尝试调用对应的枚举类的valueOf方法来获取到对应的枚举。

而我们查看枚举类的valueOf方法的实现时,就可以发现,如果从枚举类中找不到对应的枚举项的时候,就会抛出IllegalArgumentException:

1
2
3
4
5
6
7
8
9
10
typescript复制代码public static <T extends Enum<T>> T valueOf(Class<T> enumType,
String name) {
T result = enumType.enumConstantDirectory().get(name);
if (result != null)
return result;
if (name == null)
throw new NullPointerException("Name is null");
throw new IllegalArgumentException(
"No enum constant " + enumType.getCanonicalName() + "." + name);
}

关于这个问题,其实在《阿里巴巴Java开发手册》中也有类似的约定:

-w1538

这里面规定”对于二方库的参数可以使用枚举,但是返回值不允许使用枚举“。这背后的思考就是本文上面提到的内容。

扩展思考

为什么参数中可以有枚举?

不知道大家有没有想过这个问题,其实这个就和二方库的职责有点关系了。

一般情况下,A系统想要提供一个远程接口给别人调用的时候,就会定义一个二方库,告诉其调用方如何构造参数,调用哪个接口。

而这个二方库的调用方会根据其中定义的内容来进行调用。而参数的构造过程是由B系统完成的,如果B系统使用到的是一个旧的二方库,使用到的枚举自然是已有的一些,新增的就不会被用到,所以这样也不会出现问题。

比如前面的例子,B系统在调用A系统的时候,构造参数的时候使用到AType的时候就只有P_T和A_B两个选项,虽然A系统已经支持P_M了,但是B系统并没有使用到。

如果B系统想要使用P_M,那么就需要对该二方库进行升级。

但是,返回值就不一样了,返回值并不受客户端控制,服务端返回什么内容是根据他自己依赖的二方库决定的。

但是,其实相比较于手册中的规定,我更加倾向于,在RPC的接口中入参和出参都不要使用枚举。

一般,我们要使用枚举都是有几个考虑:

  • 1、枚举严格控制下游系统的传入内容,避免非法字符。
  • 2、方便下游系统知道都可以传哪些值,不容易出错。

不可否认,使用枚举确实有一些好处,但是我不建议使用主要有以下原因:

  • 1、如果二方库升级,并且删除了一个枚举中的部分枚举项,那么入参中使用枚举也会出现问题,调用方将无法识别该枚举项。
  • 2、有的时候,上下游系统有多个,如C系统通过B系统间接调用A系统,A系统的参数是由C系统传过来的,B系统只是做了一个参数的转换与组装。这种情况下,一旦A系统的二方库升级,那么B和C都要同时升级,任何一个不升级都将无法兼容。

我其实建议大家在接口中使用字符串代替枚举,相比较于枚举这种强类型,字符串算是一种弱类型。

如果使用字符串代替RPC接口中的枚举,那么就可以避免上面我们提到的两个问题,上游系统只需要传递字符串就行了,而具体的值的合法性,只需要在A系统内自己进行校验就可以了。

为了方便调用者使用,可以使用javadoc的@see注解表明这个字符串字段的取值从那个枚举中获取。

1
2
3
4
5
6
7
8
9
typescript复制代码public Class AResponse{

private Boolean success;

/**
* @see AType
*/
private String aType;
}

对于像阿里这种比较庞大的互联网公司,随便提供出去的一个接口,可能有上百个调用方,而接口升级也是常态,我们根本做不到每次二方库升级之后要求所有调用者跟着一起升级,这是完全不现实的,并且对于有些调用者来说,他用不到新特性,完全没必要做升级。

还有一种看起来比较特殊,但是实际上比较常见的情况,就是有的时候一个接口的声明在A包中,而一些枚举常量定义在B包中,比较常见的就是阿里的交易相关的信息,订单分很多层次,每次引入一个包的同时都需要引入几十个包。

对于调用者来说,我肯定是不希望我的系统引入太多的依赖的,一方面依赖多了会导致应用的编译过程很慢,并且很容易出现依赖冲突问题。

所以,在调用下游接口的时候,如果参数中字段的类型是枚举的话,那我没办法,必须得依赖他的二方库。但是如果不是枚举,只是一个字符串,那我就可以选择不依赖。

所以,我们在定义接口的时候,会尽量避免使用枚举这种强类型。规范中规定在返回值中不允许使用,而我自己要求更高,就是即使在接口的入参中我也很少使用。

最后,我只是不建议在对外提供的接口的出入参中使用枚举,并不是说彻底不要用枚举,我之前很多文章也提到过,枚举有很多好处,我在代码中也经常使用。所以,切不可因噎废食。

当然,文中的观点仅代表我个人,具体是是不是适用其他人,其他场景或者其他公司的实践,需要读者们自行分辨下,建议大家在使用的时候可以多思考一下。

关于作者:Hollis,一个对Coding有着独特追求的人,阿里巴巴技术专家,《程序员的三门课》联合作者,《Java工程师成神之路》系列文章作者。

如果您有任何意见、建议,或者想与作者交流,都可以关注公众号【Hollis】,直接后台给我留言。

本文转载自: 掘金

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

【项目实践】一文带你搞定前后端分离下的认证和授权 Sprin

发表于 2020-11-30

以项目驱动学习,以实践检验真知

前言

关于认证和授权,R之前已经写了两篇文章:

📖【项目实践】在用安全框架前,我想先让你手撸一个登陆认证

📖【项目实践】一文带你搞定页面权限、按钮权限以及数据权限

在这两篇文章中我们没有使用安全框架就搞定了认证和授权功能,并理解了其核心原理。R在之前就说过,核心原理掌握了,无论什么安全框架使用起来都会非常容易!那么本文就讲解如何使用主流的安全框架Spring Security来实现认证和授权功能。

当然,本文并不只是对框架的使用方法进行讲解,还会剖析Spring Security的源码,看到最后你就会发现你掌握了使用方法的同时,还对框架有了深度的理解!如果没有看过前两篇文章的,强烈建议先看一下,因为安全框架只是帮我们封装了一些东西,背后的原理是不会变的。

本文所有代码都放在了Github上,克隆下来即可运行!

提纲挈领

Web系统中登录认证(Authentication)的核心就是凭证机制,无论是Session还是JWT,都是在用户成功登录时返回给用户一个凭证,后续用户访问接口需携带凭证来标明自己的身份。后端会对需要进行认证的接口进行安全判断,若凭证没问题则代表已登录就放行接口,若凭证有问题则直接拒绝请求。这个安全判断都是放在过滤器里统一处理的:

认证过滤器.png

登录认证是对用户的身份进行确认,权限授权(Authorization)是对用户能否访问某个资源进行确认,授权发生都认证之后。 认证一样,这种通用逻辑都是放在过滤器里进行的统一操作:

授权过滤器.png

LoginFilter先进行登录认证判断,认证通过后再由AuthFilter进行权限授权判断,一层一层没问题后才会执行我们真正的业务逻辑。

Spring Security对Web系统的支持就是基于这一个个过滤器组成的过滤器链:

过滤器链.png

用户请求都会经过Servlet的过滤器链,在之前两篇文章中我们就是通过自定义的两个过滤器实现了认证授权功能!而Spring Security也是做的同样的事完成了一系列功能:

自定义过滤器链.png

在Servlet过滤器链中,Spring Security向其添加了一个FilterChainProxy过滤器,这个代理过滤器会创建一套Spring Security自定义的过滤器链,然后执行一系列过滤器。我们可以大概看一下FilterChainProxy的大致源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
...省略其他代码

// 获取Spring Security的一套过滤器
List<Filter> filters = getFilters(request);
// 将这一套过滤器组成Spring Security自己的过滤链,并开始执行
VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
vfc.doFilter(request, response);

...省略其他代码
}

我们可以看一下Spring Security默认会启用多少过滤器:

seucirty默认过滤器链.png

这里面我们只需要重点关注两个过滤器即可:UsernamePasswordAuthenticationFilter负责登录认证,FilterSecurityInterceptor负责权限授权。

💡Spring Security的核心逻辑全在这一套过滤器中,过滤器里会调用各种组件完成功能,掌握了这些过滤器和组件你就掌握了Spring Security!这个框架的使用方式就是对这些过滤器和组件进行扩展。

一定要记住这句话,带着这句话去使用和理解Spring Security,你会像站在高处俯瞰,整个框架的脉络一目了然。

刚才我们总览了一下全局,现在我们就开始进行代码编写了。

要使用Spring Security肯定是要先引入依赖包(Web项目其他必备依赖我在之前文章中已讲解,这里就不过多阐述了):

1
2
3
4
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

依赖包导入后,Spring Security就默认提供了许多功能将整个应用给保护了起来:

📝要求经过身份验证的用户才能与应用程序进行交互

📝创建好了默认登录表单

📝生成用户名为user的随机密码并打印在控制台上

📝CSRF攻击防护、Session Fixation攻击防护

📝等等等等……

在实际开发中,这些默认配置好的功能往往不符合我们的实际需求,所以我们一般会自定义一些配置。配置方式很简单,新建一个配置类即可:

1
2
3
java复制代码@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

在该类中重写WebSecurityConfigurerAdapter的方法就能对Spring Security进行自定义配置。

登录认证

依赖包和配置类准备好后,接下来我们要完成的第一个功能那自然是登录认证,毕竟用户要使用我们系统第一步就是登录。之前文章介绍了Session和JWT两种认证方式,这里我们来用Spring Security实现这两种认证。

最简单的认证方式

不管哪种认证方式和框架,有些核心概念是不会变的,这些核心概念在安全框架中会以各种组件来体现,了解各个组件的同时功能也就跟着实现了功能。

我们系统中会有许多用户,确认当前是哪个用户正在使用我们系统就是登录认证的最终目的。这里我们就提取出了一个核心概念:当前登录用户/当前认证用户。整个系统安全都是围绕当前登录用户展开的!这个不难理解,要是当前登录用户都不能确认了,那A下了一个订单,下到了B的账户上这不就乱套了。这一概念在Spring Security中的体现就是 💡Authentication,它存储了认证信息,代表当前登录用户。

我们在程序中如何获取并使用它呢?我们需要通过 💡SecurityContext 来获取Authentication,看了之前文章的朋友大概就猜到了这个SecurityContext就是我们的上下文对象!

这种在一个线程中横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context)。上下文对象是非常有必要的,否则你每个方法都得额外增加一个参数接收对象,实在太麻烦了。

这个上下文对象则是交由 💡SecurityContextHolder 进行管理,你可以在程序任何地方使用它:

1
java复制代码Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

可以看到调用链路是这样的:SecurityContextHolder👉SecurityContext👉Authentication。

SecurityContextHolder原理非常简单,就是和我们之前实现的上下文对象一样,使用ThreadLocal来保证一个线程中传递同一个对象!源码我就不贴了,具体可看之前文章写的上下文对象实现。

现在我们已经知道了Spring Security中三个核心组件:

📝Authentication:存储了认证信息,代表当前登录用户

📝SeucirtyContext:上下文对象,用来获取Authentication

📝SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext

他们关系如下:

securitycontextholder

Authentication中那三个玩意就是认证信息:

📝Principal:用户信息,没有认证时一般是用户名,认证后一般是用户对象

📝Credentials:用户凭证,一般是密码

📝Authorities:用户权限

现在我们知道如何获取并使用当前登录用户了,那这个用户是怎么进行认证的呢?总不能我随便new一个就代表用户认证完毕了吧。所以我们还缺一个生成Authentication对象的认证过程!

认证过程就是登录过程,不使用安全框架时咱们的认证过程是这样的:

查询用户数据👉判断账号密码是否正确👉正确则将用户信息存储到上下文中👉上下文中有了这个对象则代表该用户登录了

Spring Security的认证流程也是如此:

1
2
java复制代码Authentication authentication = new UsernamePasswordAuthenticationToken(用户名, 用户密码, 用户的权限集合);
SecurityContextHolder.getContext().setAuthentication(authentication);

和不使用安全框架一样,将认证信息放到上下文中就代表用户已登录。上面代码演示的就是Spring Security最简单的认证方式,直接将Authentication放置到SecurityContext中就完成认证了!

这个流程和之前获取当前登录用户的流程自然是相反的:Authentication👉SecurityContext👉SecurityContextHolder。

是不是觉得,就这?这就完成认证啦?这也太简单了吧。对于Spring Security来说,这样确实就完成了认证,但对于我们来说还少了一步,那就是判断用户的账号密码是否正确。用户进行登录操作时从会传递过来账号密码,我们肯定是要查询用户数据然后判断传递过来的账号密码是否正确,只有正确了咱们才会将认证信息放到上下文对象中,不正确就直接提示错误:

1
2
3
4
5
6
7
java复制代码// 调用service层执行判断业务逻辑
if (!userService.login(用户名, 用户密码)) {
return "账号密码错误";
}
// 账号密码正确了才将认证信息放到上下文中(用户权限需要再从数据库中获取,后面再说,这里省略)
Authentication authentication = new UsernamePasswordAuthenticationToken(用户名, 用户密码, 用户的权限集合);
SecurityContextHolder.getContext().setAuthentication(authentication);

这样才算是一个完整的认证过程,和不使用安全框架时的流程是一样的哦,只是一些组件之前是我们自己实现的。

这里查询用户信息并校验账号密码是完全由我们自己在业务层编写所有逻辑,其实这一块Spring Security也有组件供我们使用:

AuthenticationManager认证方式

💡AuthenticationManager 就是Spring Security用于执行身份验证的组件,只需要调用它的authenticate方法即可完成认证。Spring Security默认的认证方式就是在UsernamePasswordAuthenticationFilter这个过滤器中调用这个组件,该过滤器负责认证逻辑。

我们要按照自己的方式使用这个组件,先在之前配置类配置一下:

1
2
3
4
5
6
7
8
java复制代码@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
}

这里我们写上完整的登录接口代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@RestController
@RequestMapping("/API")
public class LoginController {
@Autowired
private AuthenticationManager authenticationManager;

@PostMapping("/login")
public String login(@RequestBody LoginParam param) {
// 生成一个包含账号密码的认证信息
Authentication token = new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword());
// AuthenticationManager校验这个认证信息,返回一个已认证的Authentication
Authentication authentication = authenticationManager.authenticate(token);
// 将返回的Authentication存到上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
return "登录成功";
}
}

注意,这里流程和之前说的流程是完全一样的,只是用户身份验证改成了使用AuthenticationManager来进行。

AuthenticationManager的校验逻辑非常简单:

根据用户名先查询出用户对象(没有查到则抛出异常)👉将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常

这个逻辑没啥好说的,再简单不过了。重点是这里每一个步骤Spring Security都提供了组件:

📝是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由💡UserDetialsService 处理,该接口只有一个方法loadUserByUsername(String username),通过用户名查询用户对象,默认实现是在内存中查询。

📝那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由💡UserDetails 来体现,该接口中提供了账号、密码等通用属性。

📝对密码进行校验大家可能会觉得比较简单,if、else搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else外还解决了密码加密的问题,这个组件就是💡PasswordEncoder,负责密码加密与校验。

我们可以看下AuthenticationManager校验逻辑的大概源码:

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复制代码public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...省略其他代码

// 传递过来的用户名
String username = authentication.getName();
// 调用UserDetailService的方法,通过用户名查询出用户对象UserDetail(查询不出来UserDetailService则会抛出异常)
UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username);
String presentedPassword = authentication.getCredentials().toString();

// 传递过来的密码
String password = authentication.getCredentials().toString();
// 使用密码解析器PasswordEncoder传递过来的密码是否和真实的用户密码匹配
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
// 密码错误则抛出异常
throw new BadCredentialsException("错误信息...");
}

// 注意哦,这里返回的已认证Authentication,是将整个UserDetails放进去充当Principal
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails,
authentication.getCredentials(), userDetails.getAuthorities());
return result;

...省略其他代码
}

UserDetialsService👉UserDetails👉PasswordEncoder,这三个组件Spring Security都有默认实现,这一般是满足不了我们的实际需求的,所以这里我们自己来实现这些组件!

加密器PasswordEncoder

首先是PasswordEncoder,这个接口很简单就两个重要方法:

1
2
3
4
5
6
7
8
9
10
java复制代码public interface PasswordEncoder {
/**
* 加密
*/
String encode(CharSequence rawPassword);
/**
* 将未加密的字符串(前端传递过来的密码)和已加密的字符串(数据库中存储的密码)进行校验
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
}

你可以实现此接口定义自己的加密规则和校验规则,不过Spring Security提供了很多加密器实现,我们这里选定一个就好。可以在之前所说的配置类里进行如下配置:

1
2
3
4
5
java复制代码@Bean
public PasswordEncoder passwordEncoder() {
// 这里我们使用bcrypt加密算法,安全性比较高
return new BCryptPasswordEncoder();
}

因为密码加密是我前面文章少数没有介绍的功能,所以这里额外提一嘴。往数据库中添加用户数据时就要将密码进行加密,否则后续进行密码校验时从数据库拿出来的还是明文密码,是无法通过校验的。比如我们有一个用户注册的接口:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Autowired
private PasswordEncoder passwordEncoder;

@PostMapping("/register")
public String register(@RequestBody UserParam param) {
UserEntity user = new UserEntity();
// 调用加密器将前端传递过来的密码进行加密
user.setUsername(param.getUsername()).setPassword(passwordEncoder.encode(param.getPassword()));
// 将用户实体对象添加到数据库
userService.save(user);
return "注册成功";
}

这样数据库中存储的密码都是已加密的了:

密码加密.png

用户对象UserDetails

该接口就是我们所说的用户对象,它提供了用户的一些通用属性:

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复制代码public interface UserDetails extends Serializable {
/**
* 用户权限集合(这个权限对象现在不管它,到权限时我会讲解)
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 用户密码
*/
String getPassword();
/**
* 用户名
*/
String getUsername();
/**
* 用户没过期返回true,反之则false
*/
boolean isAccountNonExpired();
/**
* 用户没锁定返回true,反之则false
*/
boolean isAccountNonLocked();
/**
* 用户凭据(通常为密码)没过期返回true,反之则false
*/
boolean isCredentialsNonExpired();
/**
* 用户是启用状态返回true,反之则false
*/
boolean isEnabled();
}

实际开发中我们的用户属性各种各样,这些默认属性必然是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User类,该类实现了UserDetails接口帮我们省去了重写方法的工作:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class UserDetail extends User {
/**
* 我们自己的用户实体对象,要调取用户信息时直接获取这个实体对象。(这里我就不写get/set方法了)
*/
private UserEntity userEntity;

public UserDetail(UserEntity userEntity, Collection<? extends GrantedAuthority> authorities) {
// 必须调用父类的构造方法,以初始化用户名、密码、权限
super(userEntity.getUsername(), userEntity.getPassword(), authorities);
this.userEntity = userEntity;
}
}

业务对象UserDetailsService

该接口很简单只有一个方法:

1
2
3
4
5
6
java复制代码public interface UserDetailsService {
/**
* 根据用户名获取用户对象(获取不到直接抛异常)
*/
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

咱们自己的用户业务类该接口即可完成自己的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Service
public class UserServiceImpl implements UserService, UserDetailsService {
@Autowired
private UserMapper userMapper;

@Override
public UserDetails loadUserByUsername(String username) {
// 从数据库中查询出用户实体对象
UserEntity user = userMapper.selectByUsername(username);
// 若没查询到一定要抛出该异常,这样才能被Spring Security的错误处理器处理
if (user == null) {
throw new UsernameNotFoundException("没有找到该用户");
}
// 走到这代表查询到了实体对象,那就返回我们自定义的UserDetail对象(这里权限暂时放个空集合,后面我会讲解)
return new UserDetail(user, Collections.emptyList());
}
}

AuthenticationManager校验所调用的三个组件我们就已经做好实现了!

不知道大家注意到没有,当我们查询用户失败时或者校验密码失败时都会抛出Spring Security的自定义异常。这些异常不可能放任不管,Spring Security对于这些异常都是在ExceptionTranslationFilter过滤器中进行处理(可以回顾一下前面的过滤器截图),而💡AuthenticationEntryPoint 则专门处理认证异常!

认证异常处理器AuthenticationEntryPoint

该接口也只有一个方法:

1
2
3
4
5
6
java复制代码public interface AuthenticationEntryPoint {
/**
* 接收异常并处理
*/
void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException);
}

我们来自定义一个类实现我们自己的错误处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class MyEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
// 直接提示前端认证错误
out.write("认证错误");
out.flush();
out.close();
}
}

用户传递过来账号密码👉认证校验👉异常处理,这一整套流程的组件我们就都给定义完了!现在只差最后一步,就是在Spring Security配置类里面进行一些配置,才能让这些生效。

配置

Spring Security对哪些接口进行保护、什么组件生效、某些功能是否启用等等都需要在配置类中进行配置,注意看代码注释:

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
java复制代码@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserServiceImpl userDetailsService;

@Override
protected void configure(HttpSecurity http) throws Exception {
// 关闭csrf和frameOptions,如果不关闭会影响前端请求接口(这里不展开细讲了,感兴趣的自行了解)
http.csrf().disable();
http.headers().frameOptions().disable();
// 开启跨域以便前端调用接口
http.cors();

// 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
http.authorizeRequests()
// 注意这里,是允许前端跨域联调的一个必要配置
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
// 指定某些接口不需要通过验证即可访问。登陆、注册接口肯定是不需要认证的
.antMatchers("/API/login", "/API/register").permitAll()
// 这里意思是其它所有接口需要认证才能访问
.anyRequest().authenticated()
// 指定认证错误处理器
.and().exceptionHandling().authenticationEntryPoint(new MyEntryPoint());
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 指定UserDetailService和加密器
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}

@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

其中用的最多的就是configure(HttpSecurity http)方法,可以通过HttpSecurity 进行许多配置。当我们重写这个方法时,就已经关闭了默认的表单登录方式,然后我们再配置好启用哪些组件、指定哪些接口需要认证,就搞定了!

假设现在我们有一个/API/test接口,在没有登录的时候调用该接口看下效果:

认证错误.png

我们登录一下:

登录接口.png

然后再调用测试接口:

认证通过.png

可以看到未登录时测试接口是无法正常访问的,会按照我们在EntryPoint中的逻辑返回错误提示。

总结和补充

有人可能会问,用AuthenticationManager认证方式要配置好多东西啊,我就用之前说的那种最简单的方式不行吗?当然是可以的啦,用哪种方式都随便,只要完成功能都行。其实不管哪种方式我们的认证的逻辑代码一样都没少,只不过一个是我们自己业务类全部搞定,一个是可以集成框架的组件。这里也顺带再总结一下流程:

  1. 用户调进行登录操作,传递账号密码过来👉登录接口调用AuthenticationManager
  2. 根据用户名查询出用户数据👉UserDetailService查询出UserDetails
  3. 将传递过来的密码和数据库中的密码进行对比校验👉PasswordEncoder
  4. 校验通过则将认证信息存入到上下文中👉将UserDetails存入到Authentication,将Authentication存入到SecurityContext
  5. 如果认证失败则抛出异常👉由AuthenticationEntryPoint处理

刚才我们讲的认证方式都是基于session机制,认证后Spring Security会将包含了认证信息的SecurityContext存入到session中,Key为HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY。也就是说,你完全可以通过如下方式获取SecurityContext:

1
java复制代码SecurityContext securityContext= (SecurityContext)session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)

当然,官方还是不推荐这样直接操作的,因为统一通过SecurityContextHolder操作更利于管理!使用SecurityContextHolder除了获取当前用户外,退出登录的操作也是很方便的:

1
2
3
4
5
java复制代码@GetMapping("/logout")
public String logout() {
SecurityContextHolder.clearContext();
return "退出成功";
}

session认证咱们就讲解到此,接下来咱们讲解JWT的认证。

JWT集成

关于JWT的介绍和工具类等我在前面文章已经讲的很清楚了,这里我就不额外说明了,直接带大家实现代码。

采用JWT的方式进行认证首先做的第一步就是在配置类里禁用掉session:

1
2
java复制代码// 禁用session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

注意,这里的禁用是指Spring Security不采用session机制了,不代表你禁用掉了整个系统的session功能。

然后我们再修改一下登录接口,当用户登录成功的同时,我们需要生成token并返回给前端,这样前端才能访问其他接口时携带token:

1
2
3
4
5
6
7
8
java复制代码@Autowired
private UserService userService;

@PostMapping("/login")
public UserVO login(@RequestBody @Validated LoginParam user) {
// 调用业务层执行登录操作
return userService.login(user);
}

业务层方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public UserVO login(LoginParam param) {
// 根据用户名查询出用户实体对象
UserEntity user = userMapper.selectByUsername(param.getUsername());
// 若没有查到用户 或者 密码校验失败则抛出自定义异常
if (user == null || !passwordEncoder.matches(param.getPassword(), user.getPassword())) {
throw new ApiException("账号密码错误");
}

// 需要返回给前端的VO对象
UserVO userVO = new UserVO();
userVO.setId(user.getId())
.setUsername(user.getUsername())
// 生成JWT,将用户名数据存入其中
.setToken(jwtManager.generate(user.getUsername()));
return userVO;
}

我们执行一下登录操作:

JWT登录.png

我们可以看到登录成功时接口会返回token,后续我们再访问其它接口时需要将token放到请求头中。这里我们需要自定义一个认证过滤器,来对token进行校验:

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复制代码@Component
public class LoginFilter extends OncePerRequestFilter {
@Autowired
private JwtManager jwtManager;
@Autowired
private UserServiceImpl userService;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 从请求头中获取token字符串并解析(JwtManager之前文章有详解,这里不多说了)
Claims claims = jwtManager.parse(request.getHeader("Authorization"));
if (claims != null) {
// 从`JWT`中提取出之前存储好的用户名
String username = claims.getSubject();
// 查询出用户对象
UserDetails user = userService.loadUserByUsername(username);
// 手动组装一个认证对象
Authentication authentication = new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
// 将认证对象放到上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}

过滤器中的逻辑和之前介绍的最简单的认证方式逻辑是一致的,每当一个请求来时我们都会校验JWT进行认证,上下文对象中有了Authentication后续过滤器就会知道该请求已经认证过了。

咱们这个自定义的过滤器需要替换掉Spring Security默认的认证过滤器,这样我们的过滤器才能生效,所以我们需要进行如下配置:

1
2
java复制代码// 将我们自定义的认证过滤器插入到默认的认证过滤器之前
http.addFilterBefore(loginFilter, UsernamePasswordAuthenticationFilter.class);

我们可以断点调试看一下现在的过滤器是怎样的:

自定义过滤器.png

可以看到我们自定义的过滤器已经在过滤器链中,因为没有启用表单认证所以UsernamePasswordAuthenticationFilter不会生效。

携带token访问接口时可以查看效果:

JWT认证生效.png

登录认证到此就讲解完毕了,接下来我们一鼓作气来实现权限授权!

权限授权

菜单权限主要是通过前端渲染,数据权限主要靠SQL拦截,和Spring Security没太大耦合,就不多展开了。我们来梳理一下接口权限的授权的流程:

  1. 当一个请求过来,我们先得知道这个请求的规则,即需要怎样的权限才能访问
  2. 然后获取当前登录用户所拥有的权限
  3. 再校验当前用户是否拥有该请求的权限
  4. 用户拥有这个权限则正常返回数据,没有权限则拒绝请求

完成了登录认证功能后,想必大家已经有点感觉:Spring Security将流程功能分得很细,每一个小功能都会有一个组件专门去做,我们要做的就是去自定义这些组件!Spring Security针对上述流程也提供了许多组件。

Spring Security的授权发生在FilterSecurityInterceptor过滤器中:

  1. 首先调用的是💡SecurityMetadataSource,来获取当前请求的鉴权规则
  2. 然后通过Authentication获取当前登录用户所有权限数据:💡GrantedAuthority,这个我们前面提过,认证对象里存放这权限数据
  3. 再调用💡AccessDecisionManager 来校验当前用户是否拥有该权限
  4. 如果有就放行接口,没有则抛出异常,该异常会被💡AccessDeniedHandler 处理

我们可以来看一下过滤器里大概的源码:

1
2
3
4
5
6
7
8
9
10
java复制代码public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
...省略其它代码

// 这是Spring Security封装的对象,该对象里包含了request等信息
FilterInvocation fi = new FilterInvocation(request, response, chain);
// 这里调用了父类的AbstractSecurityInterceptor的方法,认证核心逻辑基本全在父类里
InterceptorStatusToken token = super.beforeInvocation(fi);

...省略其它代码
}

父类的beforeInvocation大概源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码protected InterceptorStatusToken beforeInvocation(Object object) {
...省略其它代码

// 调用SecurityMetadataSource来获取当前请求的鉴权规则,这个ConfigAttribue就是规则,后面我会讲
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
// 如果当前请求啥规则也没有,就代表该请求无需授权即可访问,直接结束方法
if (CollectionUtils.isEmpty(attributes)) {
return null;
}

// 获取当前登录用户
Authentication authenticated = authenticateIfRequired();
// 调用AccessDecisionManager来校验当前用户是否拥有该权限,没有权限则抛出异常
this.accessDecisionManager.decide(authenticated, object, attributes);

...省略其它代码
}

老生常谈,核心流程都是一样的。我们接下来自定义这些组件,以完成我们自己的鉴权逻辑。

鉴权规则源SecurityMetadataSource

该接口我们只需要关注一个方法:

1
2
3
4
5
6
7
8
9
10
java复制代码public interface SecurityMetadataSource {
/**
* 获取当前请求的鉴权规则

* @param object 该参数就是Spring Security封装的FilterInvocation对象,包含了很多request信息
* @return 鉴权规则对象
*/
Collection<ConfigAttribute> getAttributes(Object object);

}

ConfigAttribute就是我们所说的鉴权规则,该接口只有一个方法:

1
2
3
4
5
6
7
java复制代码public interface ConfigAttribute {
/**
* 这个字符串就是规则,它可以是角色名、权限名、表达式等等。
* 你完全可以按照自己想法来定义,后面AccessDecisionManager会用这个字符串
*/
String getAttribute();
}

在之前文章中我们授权的实现全是靠着资源id,用户id关联角色id,角色id关联资源id,这样用户就相当于关联了资源,而我们接口资源在数据库中的体现是这样的:

资源表.png

这里还是一样,我们照样以资源id作为权限的标记。接下咱们就来自定义SecurityMetadataSource组件:

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复制代码@Component
public class MySecurityMetadataSource implements SecurityMetadataSource {
/**
* 当前系统所有接口资源对象,放在这里相当于一个缓存的功能。
* 你可以在应用启动时将该缓存给初始化,也可以在使用过程中加载数据,这里我就不多展开说明了
*/
private static final Set<Resource> RESOURCES = new HashSet<>();

@Override
public Collection<ConfigAttribute> getAttributes(Object object) {
// 该对象是Spring Security帮我们封装好的,可以通过该对象获取request等信息
FilterInvocation filterInvocation = (FilterInvocation) object;
HttpServletRequest request = filterInvocation.getRequest();
// 遍历所有权限资源,以和当前请求进行匹配
for (Resource resource : RESOURCES) {
// 因为我们url资源是这种格式:GET:/API/user/test/{id},冒号前面是请求方法,冒号后面是请求路径,所以要字符串拆分
String[] split = resource.getPath().split(":");
// 因为/API/user/test/{id}这种路径参数不能直接equals来判断请求路径是否匹配,所以需要用Ant类来匹配
AntPathRequestMatcher ant = new AntPathRequestMatcher(split[1]);
// 如果请求方法和请求路径都匹配上了,则代表找到了这个请求所需的权限资源
if (request.getMethod().equals(split[0]) && ant.matches(request)) {
// 将我们权限资源id返回,这个SecurityConfig就是ConfigAttribute一个简单实现
return Collections.singletonList(new SecurityConfig(resource.getId().toString()));
}
}
// 走到这里就代表该请求无需授权即可访问,返回空
return null;
}

@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
// 不用管,这么写就行
return null;
}

@Override
public boolean supports(Class<?> clazz) {
// 不用管,这么写就行
return true;
}
}

注意,我们这里返回的ConfigAttribute鉴权规则,就是我们的资源id。

用户权限GrantedAuthority

该组件代表用户所拥有的权限,和ConfigAttribute一样也只有一个方法,该方法返回的字符串就是代表着权限

1
2
3
java复制代码public interface GrantedAuthority extends Serializable {
String getAuthority();
}

将GrantedAuthority和ConfigAttribute一对比,就知道用户是否拥有某个权限了。

Spring Security对GrantedAuthority有一个简单实现SimpleGrantedAuthority,对咱们来说够用了,所以我们额外再新建一个实现。我们要做的就是在UserDetialsService中,获取用户对象的同时也将权限数据查询出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Override
public UserDetails loadUserByUsername(String username) {
UserEntity user = userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("没有找到该用户");
}
// 先将该用户所拥有的资源id全部查询出来,再转换成`SimpleGrantedAuthority`权限对象
Set<SimpleGrantedAuthority> authorities = resourceService.getIdsByUserId(user.getId())
.stream()
.map(String::valueOf)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
// 将用户实体和权限集合都放到UserDetail中,
return new UserDetail(user, authorities);
}

这样当认证完毕时,Authentication就会拥有用户信息和权限数据了。

授权管理AccessDecisionManager

终于要来到我们真正的授权组件了,这个组件才最终决定了你有没有某个权限,该接口我们只需关注一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public interface AccessDecisionManager {

/**
* 授权操作,如果没有权限则抛出异常
*
* @param authentication 当前登录用户,以获取当前用户权限信息
* @param object FilterInvocation对象,以获取request信息
* @param configAttributes 当前请求鉴权规则
*/
void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
throws AccessDeniedException, InsufficientAuthenticationException;
}

该方法接受了这几个参数后完全能做到权限校验了,我们来实现自己的逻辑:

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
java复制代码@Component
public class MyDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) {
// 如果授权规则为空则代表此URL无需授权就能访问
if (Collections.isEmpty(configAttributes)) {
return;
}
// 判断授权规则和当前用户所属权限是否匹配
for (ConfigAttribute ca : configAttributes) {
for (GrantedAuthority authority : authentication.getAuthorities()) {
// 如果匹配上了,代表当前登录用户是有该权限的,直接结束方法
if (Objects.equals(authority.getAuthority(), ca.getAttribute())) {
return;
}
}
}
// 走到这里就代表没有权限,必须要抛出异常,否则错误处理器捕捉不到
throw new AccessDeniedException("没有相关权限");
}

@Override
public boolean supports(ConfigAttribute attribute) {
// 不用管,这么写就行
return true;
}

@Override
public boolean supports(Class<?> clazz) {
// 不用管,这么写就行
return true;
}
}

授权错误处理器AccessDeniedHandler

该组件和之前的认证异常处理器一样,只有一个方法用来处理异常,只不过这个是用来处理授权异常的。我们直接来实现:

1
2
3
4
5
6
7
8
9
java复制代码public class MyDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
out.write("没有相关权限");
out.flush();
out.close();
}
}

配置

组件都定义好了,那我们接下来就是最后一步咯,就是让这些组件生效。我们的鉴权规则源组件SecurityMetadataSource和授权管理组件AccessDecisionManager必须通过鉴权过滤器FilterSecurityInterceptor来配置生效,所以我们得自己先写一个过滤器,这个过滤器的核心代码基本按照父类的写就行,主要就是属性的配置:

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
java复制代码@Component
public class AuthFilter extends AbstractSecurityInterceptor implements Filter {
@Autowired
private SecurityMetadataSource securityMetadataSource;

@Override
public SecurityMetadataSource obtainSecurityMetadataSource() {
// 将我们自定义的SecurityMetadataSource给返回
return this.securityMetadataSource;
}

@Override
@Autowired
public void setAccessDecisionManager(AccessDecisionManager accessDecisionManager) {
// 将我们自定义的AccessDecisionManager给注入
super.setAccessDecisionManager(accessDecisionManager);
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 下面的就是按照父类写法写的
FilterInvocation fi = new FilterInvocation(request, response, chain);
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
// 执行下一个拦截器
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
// 请求之后的处理
super.afterInvocation(token, null);
}
}

@Override
public Class<?> getSecureObjectClass() {
return FilterInvocation.class;
}

@Override
public void init(FilterConfig filterConfig) {}

@Override
public void destroy() {}
}

过滤器定义好了,我们回到Spring Security配置类让这个过滤器插入到原有的鉴权过滤器之前就一切都搞定啦:

1
java复制代码http.addFilterBefore(authFilter, FilterSecurityInterceptor.class);

我们可以来看下效果,没有权限的情况下访问接口:

授权失败.png

有权限的情况下访问接口:

授权通过.png

总结

整个Spring Security就讲解完毕了,我们对两个过滤器、N多个组件进行了自定义实现,从而达到了我们的功能。这里做了一个思维导图方便大家理解:

思维导图.png

别看组件这么多,认证授权的核心流程和一些概念是不会变的,什么安全框架都万变不离其宗。比如Shiro,其中最基本的概念Subject就代表当前用户,SubjectManager就是用户管理器……

在我前两篇文章中有人也谈到用安全框架还不如自己手写,确实,手写可以最大灵活度按照自己的想法来(并且也不复杂),使用安全框架反而要配合框架的定式,好像被束缚了。那安全框架对比手写有什么优势呢?我觉得优势主要有如下两点:

  1. 一些功能开箱即用,比如Spring Security的加密器,非常方便
  2. 框架的定式既是束缚也是规范,无论谁接手你的项目,一看到熟悉的安全框架就能立马上手

讲解到这里就结束了,本文所有代码、SQL语句都放在Github,克隆下来即可运行。

转载请联系公众号【RudeCrab】开启白名单

本文转载自: 掘金

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

成千上万个站点,日数据过亿的大规模爬虫是怎么实现的?

发表于 2020-11-30

图怪兽_ae7f82ddf6d63b7f1aa3f3e208778c80_88454

分布式爬虫、智能解析、消息队列、去重和调度等技术点

我们身边接触最频繁、同时也是最大的爬虫莫过于几大搜索引擎。但是搜索引擎的爬取方式和我们爬虫工程师接触的方式差异比较大,没有太大的参考价值,我们今天要讲的是舆情方向的爬虫(架构以及关键技术原理),主要涉及:

  1. 网页文本智能提取;
  2. 分布式爬虫;
  3. 爬虫 DATA/URL 去重;
  4. 爬虫部署;
  5. 分布式爬虫调度;
  6. 自动化渲染技术;
  7. 消息队列在爬虫领域的应用;
  8. 各种各样形式的反爬虫;

请大家买好瓜子,搬好凳子坐下学习,并准备好争夺文末赠送的奖品!

一、网页文本智能提取

舆情其实就是舆论情况,要掌握舆情,那么就必须掌握足够多的内容资讯。除了一些开放了商业接口的大型内容/社交类平台(例如微博)之外,其他都需要依靠爬虫去采集。因此,舆情方向的爬虫工程师需要面对的是千千万万个内容和结构都不同的站点。我们用一个图来表示他们面对的问题:

CwHXGu

没错,他们的采集器必须要能够适配千千万万个站点的结构,从风格迥异的 HTML 文本中提取出主体内容——标题、正文、发布时间和作者。

如果是你,你会用什么样的设计来满足业务需求呢?

曾经我也设想过这样的问题,在技术群里也看到有群友提出类似的问题,但是很难得到满意的答案。有的人说:

  1. 用归类法,相似的内容归类到一起,然后给一类内容配置提取规则;
  2. 用正则,提取指定标签中的内容;
  3. 用深度学习,NLP 语义分析出哪里是有意义的内容,提取出来;
  4. 用计算机视觉,让人去点击,然后按照页面相似度分类提取(其实就是归类法的自动化版本);
  5. 用算法,计算出文本的密度,然后提取;

总之各种各样的想法层出不穷,但是最后都没有听到实际应用的消息。目前来说,大部分公司使用的都是人工配配置 XPATH 规则的方式,采集的时候通过网址来匹配对应的提取规则,然后调用规则来实现多站点的爬取。这种方法很有效,而且在企业中应用已久,比较稳定,但缺点也显而易见——费时间、费人工、费钱!

偶有一天,我在微信技术群里看到有人(优秀的 Python 工程师青南)发表了一个用于自动化提取文本的算法库,GeneralNewsExtractor (以下简称 GNE)。这个库参考了武汉邮电科学研究院洪鸿辉、丁世涛、黄傲、郭致远等人编写的论文——《基于文本及符号密度的网页正文提取方法》,并在论文的基础上用 Python 代码进行了具体实现,也就是 GNE。它的原理是通过提取网页 DOM 中的文本以及其中的标点符号,以文本中标点符号的密度作为基础,使用算法从一句话延伸到一段文字和一篇文章。

xS6M3T

GNE 能够有效排除正文以外的的广告、推荐栏、介绍栏等“噪声”内容,准确识别出网页正文,且识别率高达 99%(测试选用的内容为国内主流门户/媒体平台中的文章)。

GNE 的具体算法细节以及源码解析请翻阅《Python3 网络爬虫宝典》第 5 章。

有了它,基本上就可以解决 90% 以上的舆情方向爬虫解析的需求,剩下的 10% 可以基于提取规则进行针对性调整或者完全定制化开发,解放了一大波 XPATH 工程师。

二、爬虫 DATA/URL 去重

舆情业务必须紧盯网站是否有新的内容发布,要求是越快越好,但由于各项软硬件限制,通常会要求在 30 分钟内或者 15 分钟内监听到新内容。要实现对目标网站内容变化的监听,那么我们可以选择的比较好的方式就是轮询。不停地访问网页,并且判断是否有“新内容”出现,如果有的话就执行爬取,没有“新内容”就不爬取。

那么问题来了,应用程序如何知道哪些内容是“新的”、哪些内容又是“旧的”的呢?

问题拆解一下,“新内容”就是没有爬过的内容。这时候我们需要用一个东西来记录这篇文章是否被爬取过,每次有要爬取的文章时就比对一下,这就是解决这个问题的办法。

那又依靠什么来进行比对呢?

我们都知道文章的 URL 几乎都是不变且不会重复的,因此可以选择文章的 URL 作为判定的依据,也就是把爬取过的 URL 放到一个类似列表一样的容器里存储起来,每次有待爬取的 URL 就判断它是否已经被存在容器里,如果在就说明已经爬过了,直接丢弃,进入下一个 URL 的判断流程。整体逻辑就像下面这张图一样:

ac9WnD

这就是爬虫领域的“去重”。实际上去重可以粗略的分为内容(DATA)去重和链接(URL)去重,这里我们讲的只是舆情方向的去重需求,如果是电商方向的去重,那么就不能用 URL 作为判断依据,因为电商爬虫(例如比价软件)的目的主要是判断价格的变化,这时候判断变化的依据应该是商品的关键信息(例如价格、折扣),也就是 DATA 去重。

去重的原理明白了,那用什么东西作为存放去重依据的容器呢?MySQL?Redis?MongoDB?内存?实际上大部分工程师都选择 Redis 作为存放去重依据的容器,但实际上 MySQL、MongoDB 和内存都是可以充当容器的,至于为什么会选择 Redis,它又比其他数据存储好在哪里?你可以翻阅《Python3 网络爬虫宝典》的第 3 章。

三、分布式爬虫

无论是舆情方向的爬虫还是电商方向的爬虫,要承担的爬取量都是非常大的。少则每日百万数据,多则每日数十亿数据。以往大家了解的单机爬虫,在性能和资源方面都无法满足需求。既然 1 个满足不了,那就 10 个、100 个!这就是分布式爬虫出现的背景。

众所周知,分布式和单机要面对的问题是有差异的,除了业务目标是相同的之外,分布式还要考虑多个个体之间的协作,尤其是资源的共享和竞争。

Btwyu7

在只有 1 个爬虫应用的时候,也只有它 1 个读取待爬队列、只有 1 个存储数据、只有它 1 个判断 URL 是否重复。但有几十几百个爬虫应用的时候,就需要区分先后顺序,避免出现多个爬虫应用访问同一个 URL 的情况(因为这不仅浪费时间,还浪费资源)。而且,只有 1 个爬虫应用的时候只需要把它放在 1 台计算机(服务器)上运行就可以了,但是爬虫应用突然变得这么多,又应该如何部署到不同的计算机上呢?手动一个个上传,然后一个个启动吗?

资源问题

先说资源共享和竞争的情况,为了解决 URL 待爬队列和已经爬队列的共享,那么必须将队列(也就是上面提到的存放 URL 的容器)放到一个可以公开(多个爬虫应用)访问的地方,例如部署在服务器上的 Redis。

这时候又出现一个新状况,随着数据量越来越大,要存储的 URL 越来越多,后面很有可能出现因为存储空间需求过大而导致成本递增的问题。因为 Redis 是利用内存来存储数据的,所以存放的 URL 越多就需要越多的内存,而内存又是硬件设备里价格相对较高的硬件,所以不得不考虑这个问题。

好在一个叫做布隆的人发明了一种算法——Bloom Filter(布隆过滤器),这种算法通过哈希映射的方式来标记一个对象(这里是 URL)是否存在,这样可以将内存的占用率大大降低,按 1 亿条长度为 32 字符的 URL MD5 值来计算,使用 Bloom Filter 前后的差距大约在 30倍。关于 Bloom Filter 的算法原理和代码实现解读可翻阅《Python3 网络爬虫宝典》第 3 章 。

部署问题

一个个文件上传,一次次手动运行爬虫实在是太累了。你可以向运维同事寻求技术支持,但也可以自己探寻这些能够减轻你工作量的自动化部署方式。目前业内知名的持续集成和部署莫过于 GitLab 的 GitLab Runner 和 GitHub Action,又或者是借助 K8S 的容器化来实现。但它们只能帮助你实现部署和启动,而爬虫应用的一些管理功能就指望不上了。遂,今天要给大家介绍的是另一种实现方式——使用 Crawlab。

Crawlab 是一款由知名外企工程师开发的分布式爬虫管理平台,它不仅支持 Python 语言编写的爬虫,几乎可以兼容大部分编程语言和应用程序。借助 Crawlab,我们可以将爬虫应用分散到不同的计算机(服务器)上,而且能够在可视化界面设定定时任务、查看平台上爬虫应用的状态以及环境依赖等信息。具体如下图所示:

qEQqcK

面对一款如此实用的平台工具,作为工程师的我们不禁想问:

  1. 它是如何把文件分散到不同计算机的?
  2. 它如何实现不同计算机(多节点)之间通信的?
  3. 它是如何实现多语言兼容的?
  4. ……

其中我们比较关注的多节点通信是借助 Redis 实现的,文件分散同步是借助 MongoDB 实现的。更多细节可翻阅《Python3 网络爬虫宝典》 第 6 章。

除了这样的平台之外,Python 爬虫工程师常常接触的莫过于 Scrapy 框架以及相关衍生的库。Scrapy 团队官方开发了一个名为 Scrapyd 的库,它专门用来部署 Scrapy 框架开发的爬虫应用。在部署 Scrapy 应用时,我们通常只需要执行 1 行命令就可以把爬虫程序部署到服务器上。你想不想知道背后的逻辑:

  1. 程序以什么样的形式上传到服务器的?
  2. 程序在服务器上如何运行的?
  3. 为什么可以查看到每个任务运行的开始时间和结束时间?
  4. 中途取消任务执行的功能是怎么实现的?
  5. 它的版本控制是怎么实现的?
  6. 如果不是 Scrapy 框架编写的 Python 应用,能实现像上面几点那样的监控和操作吗?

实际上 Scrapy 应用会被打包成为一个后缀为“.egg” 的压缩包,以 HTTP 的形式上传到服务器上。当服务端程序需要执行这个程序时,先将它复制到操作系统的临时文件夹,执行时将其导入到当前 Python 环境,执行完毕后删除该文件。至于它的执行时间和中断操作,实际上借助了 Python 进程接口,具体细节翻阅《Python3 网络爬虫宝典》 第 6 章。

四、自动化渲染技术

为了实现炫酷的效果,或者说为了节省静态资源对带宽的占用,很多网站都是借助 JavaScript 来实现对页面内容的优化。Python 程序本身是无法解释 JavaScript 和 HTML 代码的,因此无法获得我们在浏览器中“看到”,但实际上并不是“真实存在”的内容,因为这些内容都是由浏览器渲染出来的,只存在于浏览器中,HTML 文档里面还是那些文本、JavaScript 文件中还是那些代码,图片、视频和那些特效并不会出现在代码中,我们看到的一切都是浏览器的功劳。

由于 Python 也无法获取浏览器渲染后的内容,所以当我们像往常一样写代码爬取上面的数据时,就会发现拿到的数据和看到的并不一样,任务它就失败了。

image-20201129203201261

这时候我们就需要用到自动化渲染技术了,实际上像 Chrome 和 FireFox 这样的浏览器都开放了接口,允许其他编程语言按照协议规范操控浏览器。基于这样的技术背景,有团队开发出了像 Selenium 和 Puppeteer 这样的工具,然后我们就可以用 Python (其他语言也可以)代码来操作浏览器了。让浏览器帮助我们做一些用户名密码输入、登录按钮点击、文本和图片渲染、验证码滑动等操作,从而打破 Python 与浏览器本身的差异壁垒,借助浏览器渲染内容后再返回给 Python 程序,然后拿到和我们在网页上看到的一样的内容。

除了浏览器,APP 也有类似的情况。具体操作实践和案例细节可翻阅《Python3 网络爬虫宝典》 第 2 章。

五、消息队列在爬虫领域的应用

之前的描述中,我们并没有提到爬取时候的细节。假设这样一个正常的爬虫场景:爬虫先访问网站的文章列表页,然后根据列表页的 URL 进入详情页进行爬取。这里要注意,文章详情页的数量一定是比列表页的数量多 N 倍的,如果列表展示的是 20 条内容,那么就是多 20 倍。

如果我们需要爬取的网站很多,那么就会用到分布式爬虫。如果分布式爬虫只是把 1 个爬虫程序复制出 N 份来运行,那么就会出现资源分配不均衡的情况,因为在上面提到的这种情况下,每 1 个爬虫都需要这么干活。实际上我们可以有更好的搭配方式,让它们的资源得到最大利用。例从列表页到详情页可以抽象为生产者和消费者模型:

image-20201129204509533

4 号和 5 号爬虫应用只负责将列表页中抽取详情页的 URL,然后推送到一个队列中,另外几个爬虫程序从队列中取出详情页的 URL 进行爬取。当列表页和详情页数量差距比较大的时候,我们可以增加右侧的爬虫程序数量,差距较小的时候就减少右侧的爬虫程序(或者增加左侧的爬虫程序,具体视情况定)。

左侧的爬虫程序相对于队列这条“数据采集生产线”来说,它就是生产者,右侧爬虫程序的就是消费者。有了这样的结构,我们就可以根据实际情况对生产者或者消费者的熟练进行调整,实现资源的最大化利用。另外一个好处是当生产者拿到的 URL 越来越多,但消费者一时消费不过来时,URL 会一直存放在队列中,等消费能力上升时就能够再次实现均衡。有了这样的生产线,我们就不用担心一下突然涌来很多的 URL 或者一下突然把队列的 URL 消费一空,队列这种削峰填谷的能力除了在后端应用中大放异彩之外,在爬虫应用中也发挥了很大的作用。

关于爬虫(以及分布式爬虫)程序接入消息队列的具体实现和细节可翻阅《Python3 网络爬虫宝典》 第 4 章。

六、各种各样形式的反爬虫

你想要我偏不给!

网站可不会轻易让你爬取站点上面的内容,它们往往会从网络协议、浏览器特征、编程语言差异、人机差异等方面给爬虫工程师设置障碍,常见的有滑块验证码、拼图验证码、封 IP、检查 COOKIE、要求登录、设定复杂的加密逻辑、混淆前端代码等。

水来土掩、兵来将挡!爬虫工程师与目标网站的工程师你来我往的过招就像兵家尔虞我诈一般精彩。《Python3 反爬虫原理与绕过实战》一书囊括了市面上 80% 以上的反爬虫手段和爬虫技巧,详细解读双方所用招术,使各位看客从中学到不少使用招式。具体细节可翻阅该书,领略技术领域的江湖!

小结

今天我们一起学习了日数据量过亿的大规模爬虫实践路上的关键技术点,包括文本智能提取、分布式爬虫、爬虫部署和调度、去重、自动化渲染等。学会了这些技术并融会贯通,那么实现日数据过亿的爬虫就不是问题了。

double-book

这些经验都来自一线爬虫工程师,同时这些技术和设计都经过了长期的工作验证,能够直接应用在工作当中。

本文转载自: 掘金

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

1…760761762…956

开发者博客

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