Fork me on GitHub

Linux常用命令

Linux常用命令

一、系统信息与监控

1. uptime

查看系统运行时间及负载情况。

1
2
$ uptime
10:30:01 up 15 days, 3:22, 2 users, load average: 0.08, 0.03, 0.05
  • 输出说明:当前时间、运行时间、登录用户数、1/5/15分钟的平均负载(CPU 负载)。

2. top / htop

实时监控系统资源使用情况。

1
2
3
$ top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1234 root 20 0 245628 45892 2344 R 45.3 2.1 0:15.89 java
  • 关键操作:按 P(CPU 排序)、M(内存排序)、q 退出。

3. df / du

  • **df -h**:查看磁盘空间使用情况。
    1
    2
    3
    $ df -h
    Filesystem Size Used Avail Use% Mounted on
    /dev/sda1 50G 32G 16G 67% /
  • **du -sh /path**:统计目录大小。
    1
    2
    $ du -sh /var/log
    1.2G /var/log

二、文件与目录操作

4. ls

列出目录内容。

1
2
$ ls -alh  # -a显示隐藏文件,-l详细信息,-h人类可读大小
drwxr-xr-x 2 root root 4.0K Jun 10 10:00 logs

5. find

查找文件。

1
2
$ find /home -name "*.log" -mtime +7  # 查找7天前的日志文件
$ find /var -size +100M # 查找大于100MB的文件

6. grep

文本搜索工具。

1
2
$ grep "ERROR" /var/log/syslog       # 搜索包含"ERROR"的行
$ ps aux | grep nginx # 过滤进程

7. tar / gzip

压缩与解压文件。

1
2
$ tar -czvf backup.tar.gz /data      # 压缩
$ tar -xzvf backup.tar.gz # 解压

三、网络管理

8. netstat / ss

查看网络连接状态。

1
2
$ netstat -tulnp | grep 80           # 查看80端口占用
$ ss -ltn # 更高效的替代命令

9. curl / wget

网络请求与下载。

1
2
$ curl -I http://example.com         # 获取HTTP头信息
$ wget http://example.com/file.zip # 下载文件

10. tcpdump

抓包分析网络流量。

1
$ tcpdump -i eth0 port 80 -w capture.pcap  # 抓取80端口的流量

四、进程管理

11. ps

查看进程状态。

1
$ ps aux | grep nginx                # 显示所有进程详细信息

12. kill

终止进程。

1
2
$ kill -9 1234                       # 强制终止PID为1234的进程
$ pkill -f "python script.py" # 按名称终止进程

13. systemctl

管理系统服务。

1
2
$ systemctl start nginx              # 启动服务
$ systemctl status nginx # 查看服务状态

五、权限管理

14. chmod

修改文件权限。

1
2
$ chmod 755 script.sh                # 设置权限为rwxr-xr-x
$ chmod +x script.sh # 添加可执行权限

15. chown

修改文件所有者。

1
$ chown user:group file.txt          # 修改所有者和组

六、日志分析

16. tail / head

查看文件首尾内容。

1
2
$ tail -f /var/log/nginx/access.log  # 实时跟踪日志
$ head -n 100 file.log # 显示前100行

17. journalctl

查看系统日志(Systemd 系统)。

1
$ journalctl -u nginx --since "2023-06-10"  # 按时间和服务过滤

七、软件包管理

18. APT/YUM/DNF

包管理工具(Debian/RedHat 系)。

1
2
3
$ apt update && apt install nginx    # Debian/Ubuntu
$ yum install httpd # CentOS/RHEL
$ dnf remove php # Fedora/CentOS 8+

八、高级工具

19. awk / sed

文本处理工具。

1
2
$ awk '{print $1}' access.log        # 提取日志第一列(IP地址)
$ sed -i 's/old/new/g' file.txt # 替换文件中的文本

20. crontab

定时任务管理。

1
2
3
$ crontab -e                        # 编辑定时任务
# 每天凌晨备份
0 0 * * * /bin/bash /backup.sh

九、安全相关

21. sudo / su

权限切换。

1
2
$ sudo -u user command              # 以指定用户身份执行命令
$ su - username # 切换用户

总结

以上命令覆盖了运维工程师日常工作的核心场景,包括系统监控、文件操作、网络调试、服务管理等。建议结合以下技巧提升效率:

  • **管道符 |**:组合命令,如 cat file | grep "error"
  • **通配符 * / ?**:快速匹配文件,如 rm *.tmp
  • Tab 补全:减少输入错误。
  • 命令历史Ctrl+R 搜索历史命令。

进阶使用

一、性能分析与调试

1. strace

追踪进程的系统调用(调试程序行为)。

1
2
$ strace -p <PID>               # 追踪正在运行的进程
$ strace -e trace=open,read ls # 仅追踪文件的打开和读取操作

2. lsof

列出系统打开的文件和网络连接。

1
2
$ lsof -i :80                   # 查看占用80端口的进程
$ lsof /var/log/nginx # 查看谁在访问该目录

3. sar

系统性能历史数据分析(需安装 sysstat)。

1
2
$ sar -u 1 5                   # 每1秒采样CPU使用率,共5次
$ sar -n DEV # 查看网络接口流量历史

4. perf

性能分析工具(需安装 linux-tools-common)。

1
2
$ perf top                     # 实时查看系统性能事件
$ perf record -g ./program # 记录程序运行时的调用栈

二、文件与存储高级操作

5. rsync

高效同步文件(支持增量、排除文件)。

1
2
$ rsync -avz --delete /source/ user@remote:/dest/  # 同步并删除目标多余文件
$ rsync -avz --exclude="*.tmp" src/ dst/ # 排除临时文件

6. dd

磁盘读写与备份(谨慎使用!)。

1
2
$ dd if=/dev/sda of=disk.img bs=4M status=progress  # 备份整个磁盘
$ dd if=/dev/zero of=/swapfile bs=1G count=4 # 创建4GB交换文件

7. mount / umount

挂载与卸载文件系统。

1
2
$ mount -t nfs 192.168.1.100:/data /mnt  # 挂载NFS共享
$ mount -o remount,rw / # 以读写模式重新挂载根目录

8. ln

创建硬链接与软链接。

1
2
$ ln -s /path/to/file link_name          # 创建软链接(快捷方式)
$ ln /path/to/file hard_link # 创建硬链接(同一文件实体)

三、网络进阶工具

9. nmap

网络扫描与端口探测。

1
2
$ nmap -sV 192.168.1.1         # 扫描目标主机的服务版本
$ nmap -p 1-1000 192.168.1.10 # 扫描指定端口范围

10. mtr

结合 tracerouteping 的网络诊断工具。

1
$ mtr -r google.com            # 持续分析网络路径的丢包情况

11. iptables / nftables

防火墙规则管理。

1
2
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT  # 允许SSH访问
$ iptables -L -v -n # 列出所有规则

12. nc (netcat)

网络调试瑞士军刀。

1
2
$ nc -zv 192.168.1.1 80        # 检查端口是否开放
$ nc -l 1234 > file.txt # 监听端口接收文件

四、进程与资源控制

13. nohup

让进程在后台持久运行(忽略挂断信号)。

1
$ nohup ./script.sh > output.log 2>&1 &  # 后台运行并重定向日志

14. cgroups

限制进程资源使用(CPU/内存/磁盘等)。

1
2
# 使用systemd创建cgroup限制内存
$ systemd-run --scope -p MemoryLimit=500M ./memory_hungry_app

15. taskset

绑定进程到特定CPU核心。

1
$ taskset -c 0,1 ./program     # 将程序绑定到CPU0和CPU1

五、文本处理与脚本

16. jq

JSON数据处理工具。

1
$ curl api.example.com | jq '.data[].name'  # 提取JSON中的特定字段

17. xargs

将输入转换为命令参数。

1
2
$ find /tmp -name "*.log" | xargs rm -f     # 批量删除文件
$ ls *.csv | xargs -I {} mv {} /backup/ # 批量移动文件

18. cut / paste

按列切割或合并文本。

1
2
$ cut -d',' -f1,3 data.csv      # 提取CSV文件第1、3列
$ paste file1.txt file2.txt # 并排合并两个文件

六、安全与审计

19. openssl

SSL/TLS证书管理。

1
2
$ openssl s_client -connect example.com:443  # 检查SSL证书详情
$ openssl req -x509 -newkey rsa:4096 -nodes -keyout key.pem -out cert.pem -days 365 # 生成自签名证书

20. auditctl

审计系统调用(需安装 auditd)。

1
$ auditctl -a always,exit -S open -F path=/etc/passwd  # 监控对/etc/passwd的访问

七、容器与虚拟化

21. docker

容器管理(非原生Linux命令但广泛使用)。

1
2
$ docker ps -a                    # 列出所有容器
$ docker exec -it nginx bash # 进入运行中的容器

22. virsh

管理KVM虚拟机。

1
2
$ virsh list --all               # 列出所有虚拟机
$ virsh start centos7 # 启动指定虚拟机

八、高级技巧与组合命令

1. 快速分析日志

1
2
3
4
5
# 统计HTTP状态码出现次数
$ awk '{print $9}' access.log | sort | uniq -c | sort -nr

# 查找访问量最高的IP
$ awk '{print $1}' access.log | sort | uniq -c | sort -nr | head -10

2. 批量重命名文件

1
$ rename 's/\.old$/.new/' *.old    # 将后缀.old改为.new

3. 实时监控多个日志文件

1
$ tail -f /var/log/nginx/*.log    # 同时跟踪多个日志

4. SSH 隧道与端口转发

1
2
3
4
5
# 本地端口转发(访问本地8000即访问远程数据库)
$ ssh -L 8000:localhost:3306 user@remote_host

# 动态端口转发(本地SOCKS代理)
$ ssh -D 1080 user@remote_host

总结

掌握以上命令和技巧后,可应对以下场景:

  • 性能瓶颈分析strace + perf + sar
  • 复杂文本处理awk + jq + xargs
  • 网络故障排查tcpdump + mtr + nmap
  • 自动化运维crontab + rsync + 脚本组合

效率提升建议

  1. 将常用命令封装为别名(alias),例如:
    1
    2
    alias ll='ls -alh'
    alias myip='curl ipinfo.io/ip'
  2. 使用 tmuxscreen 管理远程会话,防止断开连接后任务终止。
  3. 结合 ansiblepuppet 实现批量服务器管理。

Java开发场景问题分析

OOM原因

1.一次性申请的太多资源

更改申请对象数量

2.内存资源耗尽未释放

找到未释放的对象进行释放

3.本身资源不够

jmap -heap 查看堆信息

4.通过dump定位

系统已经OOM挂了

提前设置 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=(设置导出路径)

系统运行中还未OOM

导出dump文件:jmap -dump:format=b,file=dump.hprof 777 (pid)

结合 jvisualvm 进行调试

查看最多跟业务有关对象 -> 找到GCRoot -> 查看线程栈

线上发生死锁定位 & 避免

定位

使用 jstack 命令进行定位死锁位置

使用 VisualVM 工具自动检测并显示死锁的线程

避免

产生死锁的四大因素

  • 互斥
  • 占有且等待
  • 不可抢占
  • 循环等待

根据以上四大原因进行死锁的避免,可以尝试破坏上述条件中的一个或者多个

订单超时自动取消实现方案

1.JDK自带的延时队列(DelayQueue)

  • 优点 :简单,不需要借组其他第三方组件,成本低
  • 缺点 :占用内存大

2.RocketMQ的定时消息

  • 优点 :使用简单,支持分布式,精度高,支持任意时刻

  • 缺点 :定时时长最 大值24小时,成本高,

3.Redis的过期监听

  • 优点 :使用简单,支持分布式

  • 缺点 : 不可靠,有可能丢失数据,维护成本高

4.定时任务分布式批处理

实现幂等性方案

  1. 前端限制(防君子,不防小人)
  2. 使用redis的setnx

扫码登录流程

1生成二维码

2.扫码

3.确认登录

Redis实现上亿用户实时积分排行榜

使用有序集合(Sorted Set)

Redis 的有序集合(Sorted Set)非常适合实现排行榜,因为它能根据分数(score)自动排序,并且支持高效的插入、更新和查询操作。

  • 键(Key):例如 leaderboard
  • 成员(Member):用户ID
  • 分数(Score):用户积分

使用 ZADD 命令添加或更新用户积分

1
ZADD leaderboard <score> <user_id>

代码示例

Service

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
@Service
public class LeaderboardService {

private static final String LEADERBOARD_KEY = "leaderboard";

@Autowired
private RedisTemplate<String, String> redisTemplate;

// 添加或更新用户积分
public void addOrUpdateUserScore(String userId, double score) {
redisTemplate.opsForZSet().add(LEADERBOARD_KEY, userId, score);
}

// 获取用户排名(从高到低)
public Long getUserRank(String userId) {
return redisTemplate.opsForZSet().reverseRank(LEADERBOARD_KEY, userId);
}

// 获取用户积分
public Double getUserScore(String userId) {
return redisTemplate.opsForZSet().score(LEADERBOARD_KEY, userId);
}

// 获取前 N 名用户
public Set<ZSetOperations.TypedTuple<String>> getTopUsers(int limit) {
return redisTemplate.opsForZSet().reverseRangeWithScores(LEADERBOARD_KEY, 0, limit - 1);
}

// 获取某个积分范围内的用户
public Set<ZSetOperations.TypedTuple<String>> getUsersInRange(double minScore, double maxScore) {
return redisTemplate.opsForZSet().reverseRangeByScoreWithScores(LEADERBOARD_KEY, minScore, maxScore);
}
}

Controller

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
@RestController
@RequestMapping("/leaderboard")
public class LeaderboardController {

@Autowired
private LeaderboardService leaderboardService;

// 添加或更新用户积分
@PostMapping("/add")
public String addOrUpdateUserScore(@RequestParam String userId, @RequestParam double score) {
leaderboardService.addOrUpdateUserScore(userId, score);
return "Score updated for user: " + userId;
}

// 获取用户排名
@GetMapping("/rank")
public Long getUserRank(@RequestParam String userId) {
return leaderboardService.getUserRank(userId);
}

// 获取用户积分
@GetMapping("/score")
public Double getUserScore(@RequestParam String userId) {
return leaderboardService.getUserScore(userId);
}

// 获取前 N 名用户
@GetMapping("/top")
public Set<LeaderboardService.ZSetOperations.TypedTuple<String>> getTopUsers(@RequestParam int limit) {
return leaderboardService.getTopUsers(limit);
}

// 获取某个积分范围内的用户
@GetMapping("/range")
public Set<LeaderboardService.ZSetOperations.TypedTuple<String>> getUsersInRange(
@RequestParam double minScore, @RequestParam double maxScore) {
return leaderboardService.getUsersInRange(minScore, maxScore);
}
}

对于上亿用户的数据量,可以考虑以下优化

  1. 分片 :将数据分散到多个 Redis 实例。
  2. 异步写入 :使用消息队列异步更新积分,减少 Redis 压力。
  3. 定期清理 :移除积分过低的用户,减少数据量。
  4. 分桶 :按照排名进行分桶,提高性能

Java

ParameterizedType 意为参数化类型(泛型)

ParameterizedType是Type的子接口,可以通过ParameterizedType获取泛型参数Class类型

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* ParameterizedType 表示参数化类型,例如 Collection<String>。
* 参数化类型在反射方法第一次需要时创建,如本包中所指定。 创建参数化类型 p 时,解析 p 实例化的泛型类型声明,并递归创建 p 的所有类型参数。 有关类型变量创建过程的详细信息,请参阅TypeVariable 。 重复创建参数化类型无效。
* 实现此接口的类的实例必须实现一个 equals() 方法,该方法等同于共享相同泛型类型声明并具有相同类型参数的任何两个实例。
*
* @since:1.5
*/
public interface ParameterizedType extends Type {

/**
* 返回一个Type对象数组,表示该类型的实际类型参数。
* 请注意,在某些情况下,返回的数组为空。 如果此类型表示嵌套在参数化类型中的非参数化类型,则会发生这种情况。
*
* @return: 表示此类型的实际类型参数的Type对象数组
* @throws TypeNotPresentException 如果任何实际类型参数引用不存在的类型声明
* @throws MalformedParameterizedTypeException 如果任何实际类型参数引用了由于任何原因无法实例化的参数化类型
* @since 1.5
*/
Type[] getActualTypeArguments();

/**
* 返回表示声明此类型的类或接口的Type对象。
*
* @return 表示声明此类型的类或接口的Type对象
* @since 1.5
*/
Type getRawType();

/**
* 返回一个Type对象,表示该类型所属的类型。 例如,如果此类型为O<T>.I<S> ,则返回O<T> 。
* 如果此类型是顶级类型,则返回null 。
*
* @return 一个Type对象,表示该类型所属的类型。 如果此类型是顶级类型,则返回null。
* @throws 如果所有者类型引用不存在的类型声明
* @throws 如果所有者类型引用了由于任何原因无法实例化的参数化类型
* @since 1.5
*/
Type getOwnerType();
}
CAP

CAP理论是分布式系统设计中的一个基本原则,由计算机科学家Eric Brewer在2000年提出。该理论指出,在分布式系统中,以下三个特性无法同时满足,最多只能实现其中两个

一致性(Consistency):所有节点在同一时间看到的数据是一致的。即,任何读操作都能获取最新的写操作结果。

可用性(Availability):每个请求都能在合理时间内得到响应,即使部分节点出现故障。

分区容错性(Partition Tolerance):系统在网络分区(节点间通信中断)的情况下仍能继续运行。

CAP理论的权衡

  • CA(一致性和可用性):放弃分区容错性,适合单点系统或网络稳定的环境。

  • CP(一致性和分区容错性):放弃可用性,确保数据一致性,适合对一致性要求高的场景,如金融系统。

  • AP(可用性和分区容错性):放弃一致性,确保系统可用性,适合对实时性要求高的场景,如社交网络。

实际应用

  • CP系统:如ZooKeeper,优先保证一致性和分区容错性。
  • AP系统:如Cassandra,优先保证可用性和分区容错性。
什么是BASE柔性事务?

BASE是基于可用、柔性状态和最终一致性这三个要素

  • 基本可用(Basically Available)保证分布式事务参与方不一定要同时在线;
  • 柔性状态(Soft state)则允许系统状态更新有一定的延迟,这个延时对客户来说不一定能够察觉到;
  • 最终一致性(Eventually consistent)通常是通过消息传递的方式保证系统的最终一致性;
事务的基本特性和隔离级别

事务基本特性ACID分别是:

原子性:指的是一个事务中的操作要么全部成功,要么全部失败

一致性:指的是数据库总是从一个一致性的状态转换到另外一个一致性的状态。比如A转账给B100块钱,假设A只有90块,支付之前我们数据库里的数据都是符合约束的,但是如果事务执行成功了,我们的数据库数据就破坏约束了,因此事务不能成功,这里我们说事务提供了一致性的保证

隔离性:指的是一个事务的修改在最终提交前,对其他事务是不可见的

持久性:指的是一旦事务提交,所做的修改就会永久保存到数据库中

隔离性有4个隔离级别,分别是:

  • read uncommit 读未提交,可能会读到其他事务未提交的数据,也叫做脏读。用户本来应该读取到id=1的用户age应该是10,结果读取到了其他事务还没有提交的事务,结果读取结果age=20,这就是脏读
  • read commit 读已提交,两次读取结果不一致,叫做不可重复读。不可重复读解决了脏读的问题,他只会读取已经提交的事务。用户开启事务读取id=1用户,查询到age=10,再次读取发现结果age=20,在同一个事务里同一个查询读取到不同的结果叫做不可重复读
  • repeatable read 可重复读,这是mysql的默认级别,就是每次读取结果都一样,但是有可能产生幻读
  • serializable 串行,一般是不会使用的,他会给每一行读取的数据加锁,会导致大量超时和锁竞争的问题
什么是分布式事务?有哪些实现方案?

在分布式系统中,一次业务处理可能需要多个应用来实现,比如用户发送一次下单请求,就涉及到订单系统创建订单、库存系统减库存,而对于一次下单,订单创建与减库存应该是要同时成功或同时失败的,但在分布式系统中,如果不做处理,就很有可能出现订单创建成功,但是减库存失败,那么解决这类问题,就需要用到分布式事务。常用解决方案有:

1.本地消息表:创建订单时,将减库存消息加入在本地事务中,一起提交到数据库存入本地消息表,然后调用库存系统,如果调用成功则修改本地消息状态为成功,如果调用库存系统失败,则由后台定时任务从本地消息表中取出未成功的消息,重试调用库存系统

2.消息队列:目前RocketMQ中支持事务消息,它的工作原理是:

  • 生产者订单系统先发送一条half消息到Broker,half消息对消费者而言是不可见的
  • 再创建订单,根据创建订单成功与否,向Broker发送commit或rollback
  • 并且生产者订单系统还可以提供Broker回调接口,当Broker发现一段时间half消息没有收到任何操作命令,则会主动调此接口来查询订单是否创建成功
  • 一旦half消息commit了,消费者库存系统就会来消费,如果消费成功,则消息销毁,分布式事务成功结束
  • 如果消费失败,则根据重试策略进行重试,最后还失败则进入死信队列,等待进一步处理

3.Seata:阿里开源的分布式事务框架,支持AT、TCC等多种模式,底层都是基于两阶段提交理论来实现的

TCC 分布式事务

你原本的一个接口,要改造为 3 个逻辑,Try(尝试)-Confirm(确定)-Cancel(取消)

  • 先是服务调用链路依次执行 Try 逻辑。
  • 如果都正常的话,TCC 分布式事务框架推进执行 Confirm 逻辑,完成整个事务。
  • 如果某个服务的 Try 逻辑有问题,TCC 分布式事务框架感知到之后就会推进执行各个服务的 Cancel 逻辑,撤销之前执行的各种操作。

这就是所谓的 TCC 分布式事务。TCC 分布式事务的核心思想,说白了,就是当遇到下面这些情况时:

  • 某个服务的数据库宕机了。
  • 某个服务自己挂了。
  • 那个服务的 Redis、Elasticsearch、MQ 等基础设施故障了。
  • 某些资源不足了,比如说库存不够这些。

TCC 分布式事务框架(国内):ByteTCC,TCC-transaction,Himly。

数据一致性模型有哪些?

强一致性:当更新操作完成之后,任何多个后续进程的访问都会返回最新的更新过的值,这种是对用户最友好的,就是用户上一次写什么,下一次就保证能读到什
么。根据 CAP理论,这种实现需要牺牲可用性。

弱一致性:系统在数据写入成功之后,不承诺立即可以读到最新写入的值,也不会具体的承诺多久之后可以读到。用户读到某一操作对系统数据的更新需要一段时
间,我们称这段时间为“不一致性窗口”。

最终一致性:最终一致性是弱一致性的特例,强调的是所有的数据副本,在经过一段时间的同步之后,最终都能够达到一个一致的状态。因此,最终一致性的本质是
需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。到达最终一致性的时间 ,就是不一致窗口时间,在没有故障发生的前提下,不一致窗口的时间主要受通信延迟,系统负载和复制副本的个数影响。最终一致性模型根据其提供的不同保证可以划分为更多的模型,包括因果一致性和会话一致性等。

最终一致性分布式事务如何保障实际生产中 99.99% 高可用?
负载均衡算法有哪些

1、轮询法:将请求按顺序轮流地分配到后端服务器上,它均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载

2、随机法:通过系统的随机算法,根据后端服务器的列表大小值来随机选取其中的一台服务器进行访问。由概率统计理论可以得知,随着客户端调用服务端的次数增多,其实际效果越来越接近于平均分配调用量到后端的每一台服务器,也就是轮询的结果

3、源地址哈希法:源地址哈希的思想是根据获取客户端的IP地址,通过哈希函数计算得到的一个数值,用该数值对服务器列表的大小进行取模运算,得到的结果便是客服端要访问服务器的序号。采用源地址哈希法进行负载均衡,同一IP地址的客户端,当服务器列表不变时,它每次都会映射到同一台后端服务器进行访问。

4、加权轮询法:不同的后端服务器可能机器的配置和当前系统的负载并不相同,因此它们的抗压能力也不相同。给配置高、负载低的机器配置更高的权重,让其处理更多的请求;而配置低、负载高的机器,给其分配较低的权重,降低其系统负载,加权轮询能很好地处理这一问题,并将请求顺序且按照权重分配到后端。

5、加权随机法:与加权轮询法一样,加权随机法也根据后端机器的配置,系统的负载分配不同的权重。不同的是,它是按照权重随机请求后端服务器,而非顺序。

6、最小连接数法:最小连接数算法比较灵活和智能,由于后端服务器的配置不尽相同,对于请求的处理有快有慢,它是根据后端服务器当前的连接情况,动态地选取其中当前积压连接数最少的一台服务器来处理当前的请求,尽可能地提高后端服务的利用效率,将负责合理地分流到每一台服务器。

分布式ID是什么,有哪些解决方案?

在开发中,我们通常会需要一个唯一ID来标识数据,如果是单体架构,我们可以通过数据库的主键,或直接在内存中维护一个自增数字来作为ID都是可以的,但对于一个分布式系统,就会有可能会出现ID冲突,此时有以下解决方案:

1.uuid,这种方案复杂度最低,但是会影响存储空间和性能

2.利用单机数据库的自增主键,作为分布式!D的生成器,复杂度适中,ID长度较之uuid更短,但是受到单机数据库性能的限制,并发量大的时候,此方案也不是最优方案

3.利用redis、zookeeper的特性来生成id,比如redis的自增命令、zookeeper的顺序节点,这种方案和单机数据库(mysql)相比,性能有所提高,可以适当选用

4.雪花算法,一切问题如果能直接用算法解决,那就是最合适的,利用雪花算法也可以生成分布式D,底层原理就是通过某台机器在某一毫秒内对某一个数字自增,这种方案也能保证分布式架构中的系统id唯一,但是只能保证趋势递增。业界存在tinyid、leaf等开源中间件实现了雪花算法。

什么是服务熔断?什么是服务降级?区别是什么?

1.服务熔断是指,当服务A调用的某个服务B不可用时,上游服务A为了保证自己不受影响,从而不再调用服务B,直接返回一个结果,减轻服务A和服务B的压力,直到服务B恢复。

2.服务降级是指,当发现系统压力过载时,可以通过关闭某个服务,或限流某个服务来减轻系统压力,这就是服务降级。

相同点:

1.都是为了防止系统崩溃

2.都让用户体验到某些功能暂时不可用

不同点:熔断是下游服务故障触发的,降级是为了降低系统负载

什么是服务雪崩?什么是服务限流?

1.当服务A调用服务B,服务B调用C,此时大量请求突然清求服务A,假如服务A本身能抗住这些请求,但是如果服务C抗不住,导致服务C请求堆积,从而服务B请求堆积,从而服务A不可用,这就是服务雪崩,解决方式就是服务降级和服务熔断

2、服务限流是指在高并发请求下,为了保护系统,可以对访问服务的请求进行数量上的限制,从而防止系统不被大量清求压垮,在秒杀中,限流是非常重要的

什么是ZAB协议

ZAB协议是Zookeeper用来实现一致性的原子广播协议,该协议描述了Zookeeper是如何实现一致性的,分为三个阶段:

1.领导者选举阶段:从Zookeeper集群中选出一个节点作为Leader,所有的写请求都会由Leader节点来处理

2.数据同步阶段:集群中所有节点中的数据要和Leader节点保持一致,如果不一致则要进行同步

3.请求广播阶段:当Leader节点接收到写请求时,会利用两阶段提交来广播该写请求,使得写请求像事务一样在其他节点上执行,达到节点上的数据实时一致

但值得注意的是,Zookeeper只是尽量的在达到强一致性,实际上仍然只是最终一致性的。

Zookeeper和Eureka的区别

Zookeeper: CP设计(强一致性),目标是一个分布式的协调系统,用于进行资源的统一管理。当节点crash后,需要进行leader的选举,在这个期间内,zk服务是不可用的

Eureka: AP设计(高可用),目标是一个服务注册发现系统,专门用于微服务的服务发现注册

  • Eureka各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而Eureka的客户端在向某个Eureka注册时如果发现连接失败,会自动切换至其他节点,只要有一台Eureka还在,就能保证注册服务可用(保证可用性),只不过查到的信息可能不是最新的(不保证强一致性)
  • 同时当Eureka的服务端发现85%以上的服务都没有心跳的话,它就会认为自己的网络出了问题,就不会从服务列表中删除这些失去心跳的服务,同时Eureka的客户端也会缓存服务信息。Eureka对于服务注册发现来说是非常好的选择。
什么是RPC

RPC,表示远程过程调用,对于Java这种面试对象语言,也可以理解为远程方法调用,RPC调用和HTTP调用是有区别的,RPC表示的是一种调用远程方法的方式,可以使用HTTP协议、或直接基于TCP协议来实现RPC,在Java中,我们可以通过直接使用某个服务接口的代理对象来执行方法,而底层则通过构造HTTP请求来调用远端的方法,所以,有一种说法是RPC协议是HTTP协议之上的一种协议,也是可以理解的。

TCP的三次握手和四次挥手

TCP协议是7层网络协议中的传输层协议,负责数据的可靠传输。
在建立TCP连接时,需要通过三次握手来建立,过程是
1.客户端向服务端发送一个SYN

2.服务端接收到SYN后,给客户端发送一个SYNACK

3.客户端接收到SYN ACK后,再给服务端发送一个ACK

在断开TCP连接时,需要通过四次挥手来断开,过程是:

1.客户端向服务端发送FIN

2.服务端接收FIN后,向客户端发送ACK,表示我接收到了断开连接的请求,客户端你可以不发数据了,不过服务端这边可能还有数据正在处理

3.服务端处理完所有数据后,向客户端发送FIN,表示服务端现在可以断开连接

4.客户端收到服务端的FIN,向服务端发送ACK,表示客户端也会断开连接了

SOA、分布式、微服务之间的关系和区别

1.分布式架构是指将单体架构中的各个部分拆分,然后部署不同的机器或进程中去,SOA和微服务基本上都是分布式架构的

2.SOA是一种面向服务的架构,系统的所有服务都注册在总线上,当调用服务时,从总线上查找服务信息,然后调用

3.微服务是一种更彻底的面向服务的架构,将系统中各个功能个体抽成一个个小的应用程序,基本保持一个应用对应的一个服务的架构

如何拆分微服务?

拆分微服务的时候,为了尽量保证微服务的稳定,会有一些基本的准则:

1.微服务之间尽量不要有业务交叉。

2.微服务之前只能通过接口进行服务调用,而不能绕过接口直接访问对方的数据。

3.高内聚,低耦合。

并发,并行,串行之间的区别

1.串行:一个任务执行完,才能执行下一个任务

2.并行(Parallelism):两个任务同时执行

3.并发 (Concurrency):两个任务整体看上去是同时执行,在底层,两个任务被拆成了很多份,然后一个一个执行,站在更高的角度看来两个任务是同时在执行的

对守护线程的理解

线程分为用户线程和守护线程,用户线程就是普通线程,守护线程就是JVM的后台线程,比如垃圾回收线程就是一个守护线程,守护线程会在其他普通线程都停止运行之后自动关闭。我们可以通过设置thread.setDaemon(true)来把一个线程设置为守护线程。

eg:垃圾回收线程就是典型的守护线程

线程池的底层工作原理

线程池内部是通过队列+线程实现的,当我们利用线程池执行任务时:

1.如果此时线程池中的线程数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务

2.如果此时线程池中的线程数量等于corePoolSize,但是缓冲队列workQueue未满,那么任务被放入缓冲中队列

3.如果此时线程池中的线程数量大于等于corePoolSize,缓冲队列workQueue满,并且线程池中的数量小于maximumPoolsize,建新的线程来处理被添加的任务

4、如果此时线程池中的线程数量大于corePoolsize,缓冲以列workoueue满,并目线程池中的数量等于maximumPoosize,那么通过 bandler所指定的笛略来外理此任务

5.当线程池中的线程数量大于corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。这样,线程池可以动态的调整池中的线程数

线程池为什么是先添加队列而不是先创建最大线程

当线程池中的核心线程都在忙时,如果继续往线程池中添加任务,那么任务会先放入队列,队列满了之后,才会新开线程。

这就相当于,一个公司本来有10个程序员,本来这10个程序员能正常的处理各种需求,但是随着公司的发展,需求在慢慢的增加,但是一开始这些需求只会增加在待开发列表中,然后这10个程序员加班加点的从待开发列表中获取需求并进行处理,但是某一天待开发列表满了,公司发现现有的10个程序员是真的处理不过来了,所以就开始新招员工了。

ThreadLocal的底层原理

1.Threadlocal是Java中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据

2.ThreadLocal底层是通过ThreadlocalMap来实现的,每个Thread对象(注意不是Threadlocal对象)中都存在一个ThreadlocalMap,Map的key为Threadlocal对象,Map的value为需要缓存的值

3.如果在线程池中使用ThreadLocal会造成内存泄漏,因为当Threadlocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadlocalMap,ThreadlocaMap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存池泄露,解决办法是,在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法,手动清除Entry对象

4.ThreadLocal经典的应用场景就是连接管理(一个线程持有一个连接,该连接对象可以在不同的方法之间进行传递,线程之间不共享同一个连接)

分布式锁的使用场景是什么?有哪些实现方案?

在单体架构中,多个线程都是属于同一个进程的,所以在线程并发执行时,遇到资源竞争时,可以利用ReentrantLock、synchronized等技术来作为锁,来控制共享资源的使用。

而在分布式架构中,多个线程是可能处于不同进程中的,而这些线程并发执行遇到资原竞争时,利用ReentrantLock、synchronized等技术是没办法来控制多个进程中的线程的,所以需要分布式锁,意思就是,需要一个分布式锁生成器,分布式系统中的应用程序都可以来使用这个生成器所提供的锁,从而达到多个进程中的线程使用同一把锁。

目前主流的分布式锁的实现方案有两种:
1zookeeper:利用的是zookeeper的临时节点、顺序节点、watch机制来实现的,zookeeper分布式锁的特点是高一致性,因为zookeeper保证的是CP,所以由它实现的分布式锁更可靠,不会出现混乱

2.redis:利用redis的setnx、lua脚本、消费订阅等机制来实现的,redis分布式锁的特点是高可用,因为redis保证的是AP,所以由它实现的分布式锁可能不可靠,不稳定**(一旦redis中的数据出现了不一致)**,可能会出现多个客户端同时加到锁的情况

AQS如何实现可重入锁

1.AQS是一个JAVA线程同步的框架。是JDK中很多锁工具的核心实现框架。

2.在AQS中,维护了一个信号量state和一个线程组成的双向链表队列。其中,这个线程队列,就是用来给线程排队的,而state就像是一个红绿灯,用来控制线程排队或者放行的。 在不同的场景下,有不用的意义。

3.在可重入锁这个场景下,state就用来表示加锁的次数。0标识无锁,每加一次锁,state就加1。释放锁state就减1。

Sychronized的偏向锁、轻量级锁、重量级锁

1.偏向锁:在锁对象的对象头中记录一下当前获取到该锁的线程ID,该线程下次如果又来获取该锁就可以直接获取到了

2.轻量级锁:由偏向铁升级而来,当一个线程获取到锁后,此时这把锁是偏向锁,此时如果有第二个线程来竞争锁,偏向锁就会升级为轻量级锁,之所以叫轻量级锁,是为了和重量级锁区分开来,轻量级锁底层是通过自旋来实现的,并不会阻塞线程

3.重量级锁:如果白旋次数过多仍然没有获取到锁,则会升级为重量级锁,重量级锁会导致线程阻塞

4.自旋锁:自旋锁就是线程在获取锁的过程中,不会去阻塞线程,也就无所谓唤醒线程,阻塞和唤醒这两个步骤都是需要操作系统去进行的,比较消耗时间,自旋锁是线程通过CAS获取预期的一个标记,如果没有获取到,则继续循环获取,如果获取到了则表示获取到了锁,这个过程线程一直在运行中,相对而言没有使用太多的操作系统资源,比较轻量。

Sychronized和ReentrantLock的区别

1.sychronized是一个关键字,ReentrantLock是一个类

2.sychronized会自动的加锁与释放锁,ReentrantLock需要程序员手动加锁与释放锁

3.sychronized的底层是JVM层面的锁,ReentrantLock是API层面的锁

4.sychronized是非公平锁,ReentrantLock可以选择公平锁或非公平锁

5.sychronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过代码中int类型的state标识来标识锁的状态

6.sychronized底层有一个锁升级的过程

ReentrantLock中tryLock()和lock方法的区别

1.tryLock()表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回true,没有加到则返回false

2.lock()表示阻塞加锁,线程会阻塞直到加到锁,方法也没有返回值

ReentrantLock中的公平锁和非公平锁的底层实现

首先不管是公平锁和非公平锁,它们的底层实现都会使用AQS来进行排队,它们的区别在于:线程在使用lock()方法加锁时,如果是公平锁,会先检查AQS队列中是否存在线程在排队,如果有线程在排队,则当前线程也进行排队,如果是非公平锁,则不会去检查是否有线程在排队,而是直接竞争锁。

不管是公平锁还是非公平锁,一旦没竞争到锁,都会进行排队,当锁释放时,都是唤醒排在最前面的线程,所以非公平锁只是体现在了线程加锁阶段,而没有体现在线程被唤醒阶段。

另外,ReentrantLock是可重入锁,不管是公平锁还是非公平锁都是可重入的。

什么是字节码?采用字节码的好处是什么?

编泽器(Javac)将Java源文件(*.java)文件编译成为字节码文件(*.class),可以做到一次编译到处运行,windows上编译好的class文件,可以直接在linux上运行,通过这种方式做跨平台,不过java的跨平台有一个前提条件,就是不同的操作系统上安装的JDK或JRE是不一样的,虽然字节码是通用的,但是需要把字节码解释成各个操作系统的机器码是需要不同的解释器的,所以针对各个操作系统需要有各自的JDK或JRE。

采用字节码的好处,一方面实现了跨平台,另外一方面也提高了代码执行的性能,编泽器在编译源代码时可以做一些编译期的优化,比如锁消除、标量替换、方法内联等。

类加载分几步?

按照Java虚拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括7个阶段

加载(Loading)-> 链接(Linking) [ 验证(Verification)->准备(Preparation)->解析(Resolution) ] -> 初始化(Initialization)->使用(Using)->卸载(Unloading)

其中

1.第一过程的加载(Loading)也称为装载

2.验证,准备,解析3个部分统称为链接(Linking)

类的初始化

使用static + final 修饰的成员变量,称为:全局变量

什么时候在链接阶段的准备环节,给此全局常量赋的值是字面量或常量。不涉及到方法或构造器的调用,除此之外,都是在初始化环节赋值的

为什么要自定义类加载器

隔离加载类

修改类加载的方式

扩展加载源

防止源码泄漏

类加载器的双亲委派模型

JVM中存在三个默认的类加载器

  1. BootstrapClassLoader
  2. ExtClassLoader
  3. AppClassLoader

AppClassLoader的父加载器是ExtClassLoader,ExtClassLoader的父加载器是BootstraClassLoader。

JVM在加载一个类时,会调用AppClassLoader的loadClass方法来加载这个类,不过在这个方法中,会先使用ExtClassLoader的loadClass方法来加载类,同样ExtClassLoader的loadClass方法中会先使用BootstrapClassLoader来加载类,如果BootstrapClassLoader加载到了就直接成功,如果BootstrapClassLoader没有加载到,那么ExtClassLoader就会自己尝试加载该类,如果没有加载到,那么则会由AppClassLoader来加载这个类

所以,双亲委派指得是,JVM在加载类时,会委派给Ext和Bootstrap进行加载,如果没加载到才由自己进行加载。

虚拟机栈大小

如何设置栈内存大小? -Xss size(-XX : ThreadStackSize)

栈的大小直接决定了函数调用的最大可达深度

一般默认为512k-1024k,取决于操作系统

jdk 5.0之前,默认栈大小:256k

jdk5.0之后,默认栈大小:1024k (Linux/mac/windows)

方法和栈帧之间存在怎么的关系

1.在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)

2.栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

栈帧内部结构

局部变量表

操作数栈

动态链接

方法返回地址

如何设置堆空间大小

-Xms 用于表示起始堆大小,等价于-XX:InitialHeapSize

-Xmx 用于表示堆区的最大内存,等价于-XX:MaxHeapSize

超出堆区的内存最大时,将会抛出OutOfMemoryError:heap异常

通常会将 -Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

如何排查JVM问题

对于还在正常运行的系统:

1.可以使用jmap来查看JVM中各个区域的使用情况

2.可以通过jstack来查看线程的运行情况,比如哪些线程阻塞、是否出现了死锁

3.可以通过jstat命令来查看垃圾回收的情况,特别是fullgc,如果发现fullgc比较频繁,那么就得 进行调优了

4.通过各个命令的结果,或者jvisualvm等工具来进行分析

5.首先,初步猜测频繁发送fullgc的原因,如果频繁发生fullgc但是又一直没有出现内存溢出,那么表示fullgc实际上是回收了很多对象了,所以这些对象最好能在younggc过程中就直接回收掉,避免这些对象进入到老年代,对于这种情况,就要考虑这些存活时间不长的对象是不是比较大,导致年轻代放不下,直接进入到了老年代,尝试加大年轻代的大小,如果改完之后,fullgc减少,则证明修改有效

6、同时,还可以找到占用CPU最多的线程,定位到具体的方法,优化这个方法的执行,看是否能避免某些对象的创建,从而节省内存

对于已经发生了OOM的系统:

1,一般生产系统中都会设置当系统发生了OOM时,生成当时的dump文件**(-Xx:+HeapDumpOnoutOfMemoryError -XX:HeapDumpPath=/usr/local/base)**

2.我们可以利用isisualvm等工具来分析dump文件

3.根据dump文件找到异常的实例对象,和异常的线程**(占用CPU高)**,定位到具体的代码

4:然后再进行详细的分析和调试

总之,调优不是一蹴而就的,需要分析、推理、实践、总结、再分析,最终定位到具体的问题

JVM有哪些垃圾回收算法

1.标记清除算法:

​ a.标记阶段:把垃圾内存标记出来

​ b.清除阶段:直接将垃圾内存回收

​ c.这种算法是比较简单的,但是有个很严重的问题,就是会产生大量的内存碎片。

2.复制算法:为了解决标记清除算法的内存碎片问题,就产生了复制算法。复制算法将内存分为大小相等的两半,每次只使用其中一半。垃圾回收时,将当前这一块的存活对象全部拷贝到另一半,然后当前这一半内存就可以直接清除。这种算法没有内存碎片,但是他的问题就在于浪费空间。而且,他的效率跟存活对象的个数有关。

3.标记压缩算法:为了解:决复制算法的缺陷,就提出了标记压缩算法。这种算法在标记阶段跟标记清除算法是一样的,但是在完成标记之后,不是直接清理垃圾内存,而是将存活对象往一端移动,然后将边界以外的所有内存直接清除。

JVM中哪些是线程共享区

堆区和方法区是所有线程共享的,栈,本地方法栈,程序计数器是每个线程独有的

一个对象从加载到JVM,再到被GC清除,都经历了什么过程

1.首先把字节码文件内容加载到方法区

2.然后再根据类信息在堆区创建对象

3.对象首先会分配在堆区中年轻代的Eden区,经过一次 Minor GC 后,对象如果存活,就会进入Suvvor区,在后续的每次 Minor GC中,如果对象一直存活,就会在Suvivor 区来回拷贝,每移动一次,年龄加1

4.当年龄超过15后,对象依然存活,对象就会进入老年代

5.如果经过FuIl GC,被标记为垃圾对象,那么就会被 GC 线程清理掉

Java语言中,GC Roots 包括那些元素

1.虚拟机栈中引用的对象

2.类静态属性引用的对象

3.方法区中常量引用的对象

4.所有被同步锁synchronization持有的对象

5.Java虚拟机内部的引用

6.反映java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等

小技巧

由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root

Java中内存泄漏的8种情况

1.静态集合类

如HashMap,LinkedList等,如果这些容器为静态的,那么它们的生命周期与JVM程序一致。长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收

2.单例模式

原因和静态集合类似。如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏

3.内部类持有外部类

4.各种连接,如数据库连接,网络连接和IO连接等

5.变量不合理的作用域

6.改变哈希值

7.缓存泄漏

8.监听器和回调

Java面向对象的三大特征以及理解

1.封装

Java中的封装是指一个类把自己内部的实现细节进行隐藏,只暴露对外的接口(setter和getter方法)。封装又分为属性的封装和方法的封装。把属性定义为私有的,它们通过setter和getter方法来对属性的值进行设定和获取。

2.继承

Java中的继承是指在一个现有类(父类)的基础上在构建一个新类(子类),子类可以拥有父类的成员变量以及成员方法(但是不一定能访问或调用,例如父类中private私有的成员变量以及方法不能访问和调用)。继承的作用就是能提高代码的复用性。子类拥有父类中的一切(拥有不一定能使用),它可以访问和使用父类中的非私有成员变量,以及重写父类中的非私有成员方法。

3.多态

多态就是指多种状态,就是说当一个操作在不同的对象时,会产生不同的结果。

在Java中,实现多态的方式有两种,一种是编译时的多态,另外一种是运行时多态,编译时的多态是通过方法的重载实现的,而运行时多态是通过方法的重写实现的。

方法的重载是指在同一个类中,有多个方法名相同的方法,但是这些方法有着不同的参数列表,在编译期我们就可以确定到底调用哪个方法。

方法的重写,子类重写父类中的方法(包括接口的实现),父类的引用不仅可以指向父类的对象,而且还可以指向子类的对象。当父类的引用指向子类的引用时,只有在运行时才能确定调用哪个方法。

其实在运行时的多态的实现,需要满足三个条件:1.继承(包括接口的实现)2.方法的重写 3.父类的引用指向子类对象

Java中有哪些类加载器

JDK自带有三个类加载器:Bootstrap ClassLoader、ExtClassLoader、AppClassLoader

BootstrapClassLoader 是 ExtClassLoader 的父类加载器,默认负责加载 %JAVA HOME%lib 下的jar包和class文件

ExtClassLoader 是 AppClassLoader 的父类加载器,负责加载 %JAVA HOME%/lib/ext 文件夹下的jar包和class类。

AppClassLoader 是自定义类加载器的父类,负责加载 classpath 下的类文件。

在Java的异常处理机制中,什么时候应该抛出异常,什么时候捕获异常?

异常相当于一种提示,如果我们抛出异常,就相当于告诉上层方法,我抛了一个异常,我处理不了这个异常,交给你来处理,而对于上层方法来说,它也需要决定自己能不能处理这个异常,是否也需要交给它的上层。

所以我们在写一个方法时,我们需要考虑的就是,本方法能否合理的处理该异常,如果处理不了就继续向上抛出异常,包括本方法中在调用另外一个方法时,发现出现了异常,如果这个异常应该由自己来处理,那就捕获该异常并进行处理。

STW

STW(Stop-The-World),指在GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。

GC评估指标

吞吐量:程序的运行时间(程序的运行时间+内存回收的时间)

暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间

内存占用:Java堆区所占的内存的大小

垃圾收集开销:相对于应用程序的执行,收集操作发生的频率

收集频率:相对于应用程序的执行,收集操作发生的频率

快速:一个对象从诞生到被回收所经历的时间

现在JVM调优标准:在最大吞吐量优先的情况下,降低停顿时间

OOM示例

堆溢出

元空间溢出

GC overhead limit exceeded(死循环)

线程溢出(在windows测试会死机,建议在虚拟机尝试)

栈上分配

发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配(JDK1.6后默认使用逃逸分析,提高性能)

JAVA中的逃逸分析,其实优化的点就在于对栈上分配的对象进行标量替换

如何优雅关闭Spring Boot?

四种优雅关闭Spring Boot应用程序的方案。

第一种是通过调用activator的shutdown接口,但需要配置认证和权限控制。

需添加依赖

shutdown接口默认关闭,需开启

第二种是调用应用程序上下文的close方法,也需要处理认证和权限问题。

第三种是调用Spring Application的方法来触发关闭钩子函数。

第四种是直接杀进程,可以在启动时将进程ID写入固定文本文件,然后执行脚本自动关闭应用程序。

Mybatis的优缺点

优点:
1.基于SQL 语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL写在XML里,解除sql与程序代码的耦合,便于统一管理;提供XML 标
签,支持编写动态 SQL语句,并可重用。

2.与JDBC 相比,减少了 50%以上的代码量,消除了JDBC大量冗余的代码,不需要手动开关连接

3.很好的与各种数据库兼容**(因为 MyBatis 使用JDBC 来连接数据库,所以只要JDBC 支持的数据库 MyBatis 都支持)**

4.能够与 Spring 很好的集成

5.提供映射标签,支持对象与数据库的 ORM 字段关系映射; 提供对象关系映射标签, 支持对象关系组件维护

缺点:

1.SQL语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL语句的功底有一定要求。

2.SQL语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。

Redis和MySQL如何保证数据一致

1.先更新Mysql,再更新Redis,如果更新Redis失败,可能仍然不一致

2.先删除Redis缓存数据,再更新Mysql,再次查询的时候在将数据添加到缓存中,这种方案能解决1方案的问题,但是在高并发下性能较低,而且仍然会出现数据不一致的问题,比如线程1删除了Redis缓存数据,正在更新Mysql,此时另外一个查询再查询,那么就会把Mysql中老数据又查到Redis中

3.延时双删,步骤是:先删除Redis缓存数据,再更新Mysql,延迟几百毫秒再删除Redis缓存数据,这样就算在更新Mysql时,有其他线程读了Mysql,把老数据读到了Redis中,那么也会被删除掉,从而把数据保持一致

#{}和${}的区别是什么?

#{}是预编译处理、是占位符,${}是字符串替换、是拼接符

Mybatis在处理**#{}时,会将sql中的#{}**替换为?号,调用 PreparedStatement 来赋值

Mybatis在处理**${}时,会将sql中的${}**替换成变量的值,调用 Statement 来赋值

使用#{}可以有效的防止 SQL注入,提高系统安全性

Spring中的设计模式
  1. 单例模式
    应用:Spring默认将Bean作为单例管理。
    优点:节省资源,保证全局唯一性。
  2. 工厂模式
    应用:通过BeanFactory或ApplicationContext创建Bean。
    优点:解耦Bean的创建和使用。
  3. 代理模式
    应用:AOP中使用动态代理(JDK或CGLIB)。
    优点:不修改目标对象代码,扩展功能。
  4. 模板方法模式
    应用:JdbcTemplate等模板类封装底层操作。
    优点:减少重复代码,提高复用性。
  5. 适配器模式
    应用:事件监听机制中适配器类分发事件。
    优点:解耦事件发布和处理。
  6. 策略模式
    应用:事务管理中通过PlatformTransactionManager选择不同事务策略。
    优点:解耦业务逻辑与事务管理。
  7. 观察者模式
    应用:事件驱动机制,ApplicationEventPublisher发布事件,ApplicationListener监听。
    优点:低耦合,灵活的事件驱动。
  8. 装饰器模式
    应用:为数据访问或事务管理对象动态添加功能。
    优点:动态扩展功能,不修改原代码。
  9. 组合模式
    应用:资源管理或配置管理中组合多个对象。
    优点:简化客户端代码,便于统一操作。
Spring中的Bean是线程安全的吗?

Spring本身并没有针对Bean做线程安全的处理,所以:

1.如果Bean是无状态的,那么Bean则是线程安全的
2.如果Bean是有状态的,那么Bean则不是线程安全的

另外,Bean是不是线程安全,跟Bean的作用域没有关系,Bean的作用域只是表示Bean的生命周期范围,对于任何生命周期的Bean都是一个对象,这个对象是不是线程安全的,还是得看这个Bean对象本身。

Spring容器启动流程是怎样的

1.在创建Spring容器,也就是启动Spring时

2.首先会进行扫描,扫描得到所有的BeanDefinition对象,并存在一个Map中

3.然后筛选出非懒加载的单例BeanDefinition进行创建Bean,对于多例Bean不需要在启动过程中去进行创建,对于多例Bean会在每次获取Bean时利用BeanDefinition去创建

4.利用BeanDefinition创建Bean就是Bean的创建生命周期,这期间包括了合并Beanlefinition、推断构造方法、实例化、属性填充、初始化前、初始化、初始化后等步骤,其中AOP就是发生在初始化后这一步骤中

5.单例Bean创建完了之后,Spring会发布一个容器启动事件

6.Spring启动结束

7.在源码中会更复杂,比如源码中会提供一些模板方法,让子类来实现,比如源码中还涉及到一些BeanFactoryPostProcessor和BeanPostProcessor的注册,Spring的扫描就是通过BenaFactoryPostProcessor来实现的,依赖注入就是通过BeanPostProcessor来实现的

8.在Spring启动过程中还会去处理@Import等注解

Spirng中的事务是如何实现的

1.Spring事务底层是基于数据库事务和AOP机制的

2.首先对于使用了@Transactional注解的Bean,Spring会创建一个代理对象作为Beal

3.当调用代理对象的方法时,会先判断该方法上是否加了@Transactional注解

4.如果加了,那么则利用事务管理器创建一个数据库连接

5.并且修改数据库连接的autocommit属性为false,禁止此连接的自动提交,这是实现Spring事务非常重要的一步

6.然后执行当前方法,方法中会执行sql

7.执行完当前方法后,如果没有出现异常就直接提交事务

8.如果出现了异常,并且这个异常是需要回滚的就会回滚事务,否则仍然提交事务

9.Spring事务的隔离级别对应的就是数据库的隔离级别

10.Spring事务的传播机制是Spring事务自己实现的,也是Spring事务中最复杂的

11.Spring事务的传播机制是基于数据库连接来做的,一个数据库连接一个事务,如果传播机制配置为需要新开一个事务,那么实际上就是先建立一个数据库连接,在此新数据库连接上执行sql

Spring事务传播机制

多个事务方法相互调用时,事务如何在这些方法间传播,方法A是一个事务的方法,方法A执行过程中调用了方法B,那么方法B有无事务以及方法B对事务的要求不同都会对
方法A的事务具体执行造成影响,同时方法A的事务对方法B的事务执行也有影响,这种影响具体是什么就由两个方法所定义的事务传播类型所决定。

1.REQUIRED(Spring默认的事务传播类型):如果当前没有事务,则自己新建一个事务,如果当前存在事务,则加入这个事务

2.SUPPORTS:当前存在事务,则加入当前事务,如果当前没有事务,就以非事务方法执行

3.MANDATORY:当前存在事务,则加入当前事务,如果当前事务不存在,则抛出异常

4.REQUIRES NEW:创建一个新事务,如果存在当前事务,则挂起该事务

5.NOT_SUPPORTED:以非事务方式执行,如果当前存在事务,则挂起当前事务

6:NEVER:不使用事务,如果当前事务存在,则抛出异常

7.NESTED:如果当前事务存在,则在嵌套事务中执行,否则REQUIRED的操作一样(开启一个事务)

Spring事务什么时候会失效?

spring事务的原理是AOP,进行了切面增强,那么失效的根本原因是这个AOP不起作用了!常见情况有如下几种

1.发生自调用,类里面使用this调用本类的方法(this通常省略),此时这个this对象不是代理类,而是UserService对象本身 ! 解决方法很简单,让那个this变成Userservice的代理类即可!
2.方法不是public的:@Transactional只能用于 public 的方法上,否则事务不会失效,如果要用在非 public 方法上,可以开启 Aspect 代理模式
3.数据库不支持事务
4.没有被spring管理
5.异常被吃掉,事务不会回滚(或者抛出的异常没有被定义,默认为RuntimeException)

String 为什么不可变?不可变有什么好处?

为什么不可变?

1)value使用final修饰

2)没有暴露成员变量

3)内部方法不会改动 value

一旦初始化之后,String 类中的方法就不会去改动 value 中的元素,需要的话都是直接新建一个 String 对象。

4)类使用final修饰,不可继承

这个设计主要是避免有人定义一个子类继承 String,然后重写 String 的方法,将这个子类设计成可变对象。我们知道在 java 中,有父类引用指向子类对象这种用法,这种情况下,我们需要一个String 对象,可能返回的是String 子类的对象,这会导致 String 看起来是可变的。所以 java 直接将 String定义成不可继承,避免出现这种情况。

不只是 String 类,其实所有的不可变类大致的设计思想都是按这四步来。后续如果我们自己想要设计一个不可变类,也可以按这四点来设计。

不可变的好处?为什么这么设计?

1)安全性

String 是 Java 中最基础也是最长使用的类,经常用于存储一些敏感信息,例如用户名、密码、网络连接等。因此,String 类的安全性对于整个应用程序至关重要。

2)节省空间——字符串常量池

通过使用常量池,内容相同的字符串可以使用同一个对象,从而节省内存空间。如果 String 是可变的,试想一下,当字符串常量池中的某个字符串对象被很多地方引用时,此时修改了这个对象,则所有引用的地方都会改变,这可能会导致预期之外的情况。

典型的使用字符串常量池的场景:json 工具类,fastjson、jackson 等。

3)线程安全
String 对象是不可修改的,如果线程尝试修改 String 对象,会创建新的 String,所以不存在并发修改同一个对象的问题。

4)性能
String 被广泛应用于 HashMap、HashSet 等哈希类中,当对这些哈希类进行操作时,例如 HashMap 的 get/put,hashCode 会被频繁调用。

由于不可变性,String 的 hashCode 只需要计算1次后就可以缓存起来,因此在哈希类中使用 String 对象可以提升性能。

IO流

1.什么是IO流

Java对数据的操作是通过流的方式,IO是java中实现输入输出的基础,它可以很方便的完成数据的输入输出操作,Java把不同的输入输出抽象为流,通过流的方式允许Java程序使用相同的方式来访问不同的输入、输出。

IO又分为流IO(java.io)和块IO(java.nio),Java.io是大多数面向数据流的输入/输出类的主要软件包。此外,Java也对块传输提供支持,在核心库 java.nio中采用的便是块IO。流IO的好处是简单易用,缺点是效率较低。块IO效率很高,但编程比较复杂。

2. IO流原理

IO流是基于流的概念,它将数据的输入和输出看作是一个连续的流。数据从一个地方流向另一个地方,流的方向可以是输入(读取数据)或输出(写入数据)。Java中的IO流分为字节流和字符流两种类型,分别用于处理字节数据和字符数据。

IO流的原理是通过流的管道将数据从源头传输到目标地。源头可以是文件、网络连接、内存等,而目标地可以是文件、数据库、网络等。IO流提供了一组丰富的类和方法来实现不同类型的输入和输出操作。

3.IO流分类
Java中的IO流可以按照数据的类型和流的方向进行分类。

1.按数据类型分类
字节流(Byte Stream):以字节为单位读写数据,适用于处理二进制数据,如图像、音频、视频等。常见的字节流类有InputStream和OutputStream。

字符流(Character Stream):以字符为单位读写数据,适用于处理文本数据。字符流会自动进行字符编码和解码,可以处理多国语言字符。常见的字符流类有Reader和Writer。

2 按流的方向分类
输入流(Input Stream):用于读取数据。输入流从数据源读取数据,如文件、网络连接等。常见的输入流类有FileInputStream、ByteArrayInputStream、SocketInputStream等。

输出流(Output Stream):用于写入数据。输出流将数据写入到目标地,如文件、数据库、网络等。常见的输出流类有FileOutputStream、ByteArrayOutputStream、SocketOutputStream等。

4.IO流的使用场景
IO流主要用于处理输入和输出操作,适用于以下场景:

读写文件:IO流可以方便地读取和写入文件中的数据,从而实现文件的读写操作,例如读取配置文件、处理日志文件、读取用户上传的文件等。

网络通信:IO流可以用于处理网络通信中的数据输入和输出,例如通过Socket进行网络通信时,可以使用IO流来传输数据。

数据库操作:IO流可以将数据从程序中传输到数据库中,或者从数据库中读取数据到程序中,从而实现数据库的读写操作。

内存操作:IO流也可以用于处理内存中的数据输入和输出,例如通过ByteArrayInputStream和ByteArrayOutputStream可以在内存中读写数据。

用户交互:IO流可以用于处理用户输入和输出,例如从控制台读取用户输入的数据,或者向控制台输出提示信息和结果。

ArrayList线程不安全的几种表现,怎么解决?

一、线程不安全的三种表现

1.空指针异常

2.数组越界异常

3.并发修改异常

二、解决方法

1.将ArrayList替换成Vector

1
Vector<Integer> arrayList = new Vector<>();

2.Collections.synchronizedList()

1
List<Integer> arrayList = Collections.synchronizedList(new ArrayList<>());

3.使用CopyOnWriteArrayList

1
List<Integer> arrayList1 = new CopyOnWriteArrayList<>();

CopyOnWriteArrayList是一个线程安全的ArrayList,其实现原理是读写分离,其对写操作使用ReentrantLock来上锁,对读操作则不加锁;CopyOnWriteArrayList在写操作的时候,会将list中的数组拷贝一份副本,然后对其副本进行操作(如果此时其他线程需要读的事,那么其他线程读取的是原先的没有修改的数组,如果其他写操作的线程要进行写操作,需要等待正在写的线程操作完成,释放ReentrantLock后,去获取锁才能进行写操作),写操作完成后,会讲list中数组的地址引用指向修改后的新数组地址。

总结
1、本文介绍了ArrayList在多线程的情况下可能会出现的三种异常,并分析了原因,结尾给出了三种解决ArrayList线程不安全的方案,一和二两种方法都是将所有的方法都加锁,那会导致效率低下,只能一个线程操作完,下一个线程获取到锁才能操作。

2、CopyOnWriteArrayList由于写时进行复制,内存里面同时存在两个对象占用内存,如果对象过大容易发送YongGc和FullGc,如果使用场景的写操作十分频繁的话,建议还是不要实现CopyOnWriteArrayList。

SpringMVC是如何处理一个请求
Spring中的Bean的生命周期有哪些步骤
What is AOP?

AOP(Aspect OrientedProgramming):面向切面编程,面向切面编程(也叫面向方面编程),是目前软件开发中的一个热点,也是Spring框架中的一个重要内容。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

为什么需要面向切面编程

面向对象编程(OOP)的好处是显而易见的,缺点也同样明显。当需要为多个不具有继承关系的对象添加一个公共的方法的时候,例如日志记录、性能监控等,如果采用面向对象编程的方法,需要在每个对象里面都添加相同的方法,这样就产生了较大的重复工作量和大量的重复代码,不利于维护。面向切面编程(AOP)是面向对象编程的补充,简单来说就是统一处理某一“切面”的问题的编程思想。如果使用AOP的方式进行日志的记录和处理,所有的日志代码都集中于一处,不需要再每个方法里面都去添加,极大减少了重复代码。

技术要点

  1. 通知(Advice)包含了需要用于多个应用对象的横切行为,完全听不懂,没关系,通俗一点说就是定义了“什么时候”和“做什么”。
  2. 连接点(Join Point)是程序执行过程中能够应用通知的所有点。
  3. 切点(Poincut)是定义了在“什么地方”进行切入,哪些连接点会得到通知。显然,切点一定是连接点。
  4. 切面(Aspect)是通知和切点的结合。通知和切点共同定义了切面的全部内容——是什么,何时,何地完成功能。
  5. 引入(Introduction)允许我们向现有的类中添加新方法或者属性。
  6. 织入(Weaving)是把切面应用到目标对象并创建新的代理对象的过程,分为编译期织入、类加载期织入和运行期织入。

通知类型

  1. 前置通知(@Before):在目标方法调用之前调用通知
  2. 后置通知(@After):在目标方法完成之后调用通知
  3. 环绕通知(@Around):在被通知的方法调用之前和调用之后执行自定义的方法
  4. 返回通知(@AfterReturning):在目标方法成功执行之后调用通知
  5. 异常通知(@AfterThrowing):在目标方法抛出异常之后调用通知
Spring AOP常见的失效场景

1.当前类没有被 Spring 容器所管理。Spring 的 AOP 是在 Bean 创建的初始化后阶段进行的,如果当前类没有被 Spring 容器所管
理,那么它的 Spring AOP 功能肯定会失效。

2.同一个类中方法的调用

3.内部类方法的调用。(该方式会直接调用内部类实例对象的方法,同样没有使用代理对象,所以 AOP 会失效)

4.私有方法。(私有方法,代理对象是无法调用的,所以 AOP 会失效)

5.static 修饰的方法。(因为 static 修饰的方法属于类对象,而不属于对象实例,所以无法被代理对象调用。)

6.final 修饰的方法。(因为被 final 修饰的方法是无法被重写的,所以代理对象也是无法调用的。)

Spring的Aop的完整实现流程

以 JavaConfig为主

当@EnableAspectJAutoProxy 会通过@Import注册一个BeanPostProcessor处理AOP

1.解祈切面:在Bean创建之前的第一个Bean后置处理器会去解析切面(解析切面中通知、切点,一个通知就会解析成一个advisor(通知、切点))

2.创建动态代理:正常的Bean初始化后调用BeanPostProcessor 拿到之前缓存的advisor,再通过advisor中poitcut判断当前Bean是合微功点表达式业配,如果匹配,就会为Bean创建动态代理(创建方式1.jdk动态代理2.cglib)。

3.调用:拿到动态代理对象,调用方法就会判断当前方法是否增强的方法,就会通过调用链的方式依次去执行通知.

IOC的定义

Spring IOC(Inversion of Control,控制反转)是Spring框架的核心思想之一,它通过将对象的创建、依赖注入和生命周期管理交给Spring容器来管理,从而降低了代码的耦合度,提高了程序的灵活性和可维护性。以下是对Spring IOC的解释

什么是IOC?

IOC是一种设计原则,它将传统程序中由开发者手动控制的对象的创建和依赖关系交给容器来管理。

  • 传统方式:开发者通过new关键字创建对象,并手动管理对象之间的依赖关系。
  • IOC方式:对象的创建和依赖关系由Spring容器负责,开发者只需通过配置或注解声明依赖关系。

IOC的核心思想

  • 控制反转 : 将对象的控制权从开发者手中转移到Spring容器中。
  • 依赖注入 (DI) : Spring容器通过依赖注入的方式,将对象所需的依赖关系自动注入到对象中。

IOC的工作流程

  • 加载配置:Spring容器加载配置文件或扫描注解,读取Bean的定义。

  • 创建Bean实例:根据Bean的定义,通过反射创建Bean的实例。

  • 依赖注入:根据Bean的依赖关系,将所需的依赖注入到Bean中。

  • 初始化Bean:调用Bean的初始化方法(如@PostConstruct或init-method)。

  • 使用Bean:将Bean提供给应用程序使用。

  • 销毁Bean:在容器关闭时,调用Bean的销毁方法(如@PreDestroy或destroy-method)。

SpringIOC容器的加载过程

从概念态到定义态的过程(1-6)

1、实例化一个ApplicationContext的对象

2、调用bean工厂后置处理器完成扫描

3、循环解析扫描出来的类信息;(就是有写@component 注解)

4、实例化一个BeanDefinition对象来存储解析出来的信息 (存入一个Map)

5、把实例化好的beanDefinition对象put到beanDefinitionMap当中缓存起来,以便后面实例化bean;

6、再次调用其他bean工厂后置处理器;

从定义态到纯净态(7-9)

7、当然spring还会干很多事情,比如国际化,比如注册BeanPostProcessor等等,如果我们只关心如何实例化一个bean的话那么这一步是spring调用finishBeanFactoryInitialization方法来实例化单例的bean,实例化之前spring要做验证,需要遍历所有扫描出来的类,依次判断这个bean是否Lazy,是否prototype,是否abstract等等;

8、如果验证完成spring在实例化一个bean之前需要推断构造方法,因为spring实例化对象是通过构造方法反射,故而需要知道用哪个构造方法;

9、推断完构造方法之后spring调用构造方法反射实例化一个对象;注意我这里说的是对象、对象、对象;这个时候对象已经实例化出来了,但是并不是一个完整的bean,最简单的体现是这个时候实例化出来的对象属性是没有注入,所以不是一个完整的bean

从纯净态到成熟态

10、spring处理合并后的beanDefinition

11、判断是否需要完成属性注入

12、如果需要完成属性注入,则开始注入属性

初始化

[

​ 13、判断bean的类型回调Aware接口

​ 14、调用生命周期回调方法 (13,14如果需要AOP就创建AOP动态代理)

​ 15、如果需要代理则完成代理

]

创建完成

16、put到单例池——bean完成——存在spring容器当中

Spring IOC的扩展点

1.执行BeanFactoryPostProcessor的postProcessBeanFactory方法(作用:在注册BeanDefinition的可以对beanFactory进行扩展,调用时机:IOC加载时注册BeanDefinition的时候会调用) 后

2.执行BeanDefinitionRegistryPostProcessor的postProcessBeanDefinitionRegistry方法(作用:动态注册BeanDefinition,调用时机:IOC加载时注册BeanDefinition的时候会调用) 先

3.加载BeanPostProcessor实现类**:在Bean的生命周期会调用9次Bean的后置处理器**

Spring IOC的实现机制

简单来说就是:简单工厂+反射

什么是工厂模式:很简单,就是调用一个方法(工厂方法)根据传入的参数返回一个对象。

IOC的优点

1、集中管理对象,方便维护

2、减低耦合度

3、IOC容器支持懒汉式和饿汉式的方式加载, 默认单例

springboot项目哪里用到了 AOP?怎么用的?

AOP(Aspect-Oriented Programming:面向切面编程) 能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,提高系统可拓展性和可维护性。

一般项目主要有下面这些地方用到了 AOP

1.基于 AOP 实现统一的日志管理。

2.基于 Redisson + AOP 实现了接口防刷,一个注解即可限制接口指定时间内单个用户可以请求的次数。

3.基于 Spring Security 提供的 @PreAuthorize 实现权限控制,其底层也是基于 AOP。

日志记录
利用 AOP 方式记录日志,只需要在 Controller 的方法上使用自定义 @Log 日志注解,就可以将用户操作记录到数据库。

1
2
3
4
5
6
@Log(description = "新增用户")
@PostMapping(value = "/users")
public ResponseEntity create(@Validated @RequestBody User resources){
checkLevel(resources);
return new ResponseEntity(userService.create(resources),HttpStatus.CREATED);
}

AOP 切面类 LogAspect用来拦截带有 @Log 注解的方法并处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Aspect
@Component
public class LogAspect {

private static final Logger logger = LoggerFactory.getLogger(LogAspect.class);

// 定义切点,拦截带有 @Log 注解的方法
@Pointcut("@annotation(com.example.annotation.Log)") // 这里需要根据你的实际包名修改
public void logPointcut() {
}

// 环绕通知,用于记录日志
@Around("logPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//...
}
}

限流
利用 AOP 方式对接口进行限流,只需要在 Controller 的方法上使用自定义的 @RateLimit 限流注解即可。

1
2
3
4
5
6
7
/**
* 该接口 60 秒内最多只能访问 10 次,保存到 redis 的键名为 limit_test,
*/
@RateLimit(key = "test", period = 60, count = 10, name = "testLimit", prefix = "limit")
public int test() {
return ATOMIC_INTEGER.incrementAndGet();
}

AOP 切面类 RateLimitAspect用来拦截带有 @RateLimit 注解的方法并处理:

1
2
3
4
5
6
7
8
9
@Slf4j
@Aspect
public class RateLimitAspect {
// 拦截所有带有 @RateLimit 注解的方法
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
//...
}
}

关于限流实现,并没有自己写 Redis Lua 限流脚本,而是利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。

权限控制
Spring Security 使用 AOP 进行方法拦截。在实际调用 update 方法之前,Spring 会检查当前用户的权限,只有用户权限满足对应的条件才能执行。

1
2
3
4
5
6
7
@Log(description = "修改菜单")
@PutMapping(value = "/menus")
// 用户拥有 `admin`、`menu:edit` 权限中的任意一个就能能访问`update`方法
@PreAuthorize("hasAnyRole('admin','menu:edit')")
public ResponseEntity update(@Validated @RequestBody Menu resources){
//...
}
SpringBoot是如何启动Tomcat的

1.首先,SpringBoot在启动时会先创建一个Spring容器
2.在创建Spring容器过程中,会利用@ConditionalOnClass技术来判断当前classpath中是否存在Tomcat依赖,如果存在则会生成一个启动Tomcat的Bean
3.Spring容器创建完之后,就会获取启动Tomcat的Bean,并创建Tomcat对象,并绑定端口等,然后启动Tomcat

SpringBoot中常用注解及其底层实现

1.@SpringBootApplication注解:这个注解标识了一个SpringBoot工程,它实际上是另外三个注解的组合,这三个注解是:
a.@SpringBootConfiquration:这个注解实际就是一个@Configuration,表示启动类也是一个配置类
b.@EnableAutoConfiguration:向Spring容器中导入了一个selector,用来加载Classpath下springFactories中所定义的自动配置类,将这些自动加载为配置Bean
c.@ComponentScan:标识扫描路径,因为默认是没有配置实际扫描路径,所以SpringBoot扫描的路径是启动类所在的当前目录
2.@Bean注解:用来定义Bean,类似于XML中的< bean >标签,Spring在启动时,会对加了@Bean注解的方法进行解析,将方法的名字做为beanName,并通过执行方法得到bean对象
3.@Controller、@Service、@ResponseBody、@Autowired等常见注解

java反射的原理

反射之中包含了一个「反」字,所以想要解释反射就必须先从「正」开始解释。

一般情况下,我们使用某个类时必定知道它是什么类,是用来做什么的。于是我们直接对这个类进行实例化,之后使用这个类对象进行操作。

1
2
Apple apple = new Apple(); //直接初始化,「正射」
apple.setPrice(4);

上面这样子进行类对象的初始化,我们可以理解为「正」。

而反射则是一开始并不知道我要初始化的类对象是什么,自然也无法使用 new 关键字来创建对象了。

这时候,我们使用 JDK 提供的反射 API 进行反射调用:

1
2
3
4
5
Class clz = Class.forName("com.chenshuyi.reflect.Apple");
Method method = clz.getMethod("setPrice", int.class);
Constructor constructor = clz.getConstructor();
Object object = constructor.newInstance();
method.invoke(object, 4);

上面两段代码的执行结果,其实是完全一样的。但是其思路完全不一样,第一段代码在未运行时就已经确定了要运行的类(Apple),而第二段代码则是在运行时通过字符串值才得知要运行的类(com.chenshuyi.reflect.Apple)。

所以说什么是反射?

反射就是在运行时才知道要操作的类是什么,并且可以在运行时获取类的完整构造,并调用对应的方法。

@Component和@Bean的区别在哪里?

1.用途不用

@Component用于标识普通类

@Bean是在配置类中声明和配置Bean对象

2.使用方法不同

@Component是一个类级别的注解,Spring通过@ComponentScan注解扫描并注册为Bean

@Bean通过方法级别的注解使用,在配置类中手动声明和配置Bean

3.控制权不同

@Component注解修饰的类是由Spring框架来创建和初始化的

@Bean注解允许开发人员手动控制Bean的创建和配置过程

SpringBoot字段注入和构造函数注入的区别

在使用Spring开发项目时,我们经常需要使用依赖注入来管理对象之间的依赖关系。Spring提供了多种依赖注入方式,如构造函数注入、Setter方法注入和字段注入等。这些方式各有优缺点,需要根据具体情况选择合适的注入方式。

在本文中,我将分享我在开发过程中遇到的一些问题,以及我对这些问题的思考和解决方法。主要涉及以下几个方面:

  1. 字段注入和构造函数注入的区别和联系
  2. 为什么字段注入和Setter方法注入不会导致循环依赖的问题,而构造函数注入会导致循环依赖的问题
  3. 为什么Spring不推荐使用字段注入,而推荐使用构造函数注入

什么是字段注入和构造函数注入?
在SpringBoot中,我们可以使用@Autowired注解来实现依赖注入,即让Spring容器自动为我们的类提供所需的对象。有三种常见的注入方式:字段注入,Setter方法注入和构造函数注入。

  1. 字段注入:直接在类的属性上使用@Autowired注解,无需编写额外的代码。
  2. Setter方法注入:在类的Setter方法上使用@Autowired注解,需要编写相应的Setter方法。
  3. 构造函数注入:在类的构造函数上使用@Autowired注解,需要编写相应的构造函数。

下面是一个简单的例子,假设我们有一个UserService接口和一个UserServiceImpl实现类,以及一个UserController类,我们想要在UserController中使用UserService对象。

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
// UserService接口
public interface UserService {
void saveUser(User user);
}

// UserServiceImpl实现类
@Service
public class UserServiceImpl implements UserService {
@Override
public void saveUser(User user) {
// 保存用户到数据库
}
}

// UserController类
@Controller
public class UserController {
// 字段注入
@Autowired
private UserService userService;

// Setter方法注入
// private UserService userService;
// @Autowired
// public void setUserService(UserService userService) {
// this.userService = userService;
// }

// 构造函数注入
// private final UserService userService;
// @Autowired
// public UserController(UserService userService) {
// this.userService = userService;
// }

public void createUser(User user) {
userService.saveUser(user);
// 其他逻辑
}
}

这两种方式有什么区别?
这两种方式在功能上没有区别,都可以实现依赖注入。但是在一些细节上有一些差异,主要有以下几点 :

  1. 可读性:字段注入的代码更简洁,依赖项被隔离在一个地方,更容易阅读。构造函数注入的代码更冗长,当有多个依赖项时,构造函数可能会变得臃肿。
  2. 不变性:构造函数注入支持不变性,即可以将依赖项声明为final类型,保证对象创建后不会被修改。这有利于线程安全性,状态安全性和可读性。字段注入不支持不变性,无法将依赖项声明为final类型。
  3. 状态安全性:构造函数注入保证了对象被实例化为完整状态或完全不被实例化。如果使用者使用new关键字创建对象,则必须提供所有依赖项作为参数。字段注入无法保证状态安全性,如果使用者使用new关键字创建对象,则无法设置对象的状态。唯一的选择是使用反射设置私有字段。
  4. 循环依赖:循环依赖是指两个或多个类相互依赖对方,导致无法正常创建对象。例如,如果A类依赖B类,B类依赖A类,则会产生循环依赖。循环依赖是一种不良的设计模式,应该避免。

字段注入和Setter方法注入的联系
字段注入和Setter方法注入都是通过反射来实现的,它们都可以在类的属性上使用@Autowired注解来标注依赖关系。它们的区别在于,字段注入是直接在属性上使用@Autowired注解,而Setter方法注入是在属性对应的Setter方法上使用@Autowired注解。

字段注入和Setter方法注入的联系有以下几点:

  1. 它们都是基于名称或者类型来匹配依赖关系的。如果属性名字或者Setter方法名字与Bean定义中的id或者name相同,则按照名称匹配;否则按照属性类型或者Setter方法参数类型匹配。
  2. 它们都不支持不变性,即无法将依赖项声明为final类型。这可能会导致线程安全性,状态安全性和可读性的问题。
  3. 它们都可以避免循环依赖的问题,因为它们是在对象创建后才进行依赖注入的,而不是在对象创建时。这样可以避免构造函数注入时可能出现的循环依赖异常。

为什么字段注入和Setter方法注入不会导致循环依赖的问题?
循环依赖是指两个或多个类相互依赖对方,导致无法正常创建对象。例如,如果A类依赖B类,B类依赖A类,则会产生循环依赖。循环依赖是一种不良的设计模式,应该避免。

在Spring中,循环依赖主要发生在构造函数注入的情况下,因为构造函数注入是在对象创建时就进行依赖注入的,而不是在对象创建后。这样就会导致一个死锁的情况,即A类要等待B类创建完成才能创建,而B类又要等待A类创建完成才能创建。

字段注入和Setter方法注入不会导致循环依赖的问题,因为它们是在对象创建后才进行依赖注入的,而不是在对象创建时。这样就可以避免死锁的情况,即A类和B类都可以先创建出来,然后再互相注入对方。

Spring解决循环依赖的方法是通过提前暴露半成品对象(Early-Stage Object)来解决。半成品对象是指已经实例化但还没有完成初始化的对象。Spring会将半成品对象放入一个缓存中,当其他对象需要依赖它时,就可以从缓存中获取它,并进行后续的属性赋值和初始化操作。

两种方式的流程

字段注入和构造函数注入的流程如下:

字段注入:当IOC容器创建Bean时,它会先通过反射调用无参构造函数来实例化对象,然后再通过反射获取属性上的@Autowired注解,并根据名称或者类型来匹配依赖关系,最后通过反射将依赖关系注入到属性中。

构造函数注入:当IOC容器创建Bean时,它会先通过反射获取构造函数上的@Autowired注解,并根据名称或者类型来匹配依赖关系,然后再通过反射调用带参构造函数来实例化对象,并将依赖关系作为参数传递进去。

为什么Spring不推荐使用字段注入?

Spring不推荐使用字段注入的原因有以下几点:

字段注入违反了单一职责原则,因为它使得添加新的依赖项非常容易,而不会引起警告。这可能导致类有太多的责任和关注点,需要进一步的检查和重构。

字段注入隐藏了依赖关系,因为它没有使用公共接口(方法或构造函数)来清楚地与依赖项通信。这样就不利于类的可测试性和可重用性,也不利于依赖项的可选性和强制性的区分。

字段注入导致了依赖注入容器的耦合,因为它使得类无法脱离容器独立运行。这意味着类不能通过new关键字来创建,也不能切换到其他的依赖注入框架。

字段注入不支持不变性,因为它无法将依赖项声明为final类型,也无法注入静态变量。这可能会导致线程安全性,状态安全性和可读性的问题。

总结

字段注入和构造函数注入都是Spring中常用的依赖注入方式,它们各有优缺点,开发人员应根据具体情况选择合适的注入方式。一般来说,以下几点可以作为参考:

  1. 如果依赖关系是必须的,且不需要重新配置或者重新注入,则推荐使用构造函数注入,因为它可以支持不变性和状态安全性。
  2. 如果依赖关系是可选的,或者需要重新配置或者重新注入,则推荐使用字段注入或者Setter方法注入,因为它们可以提高代码的简洁性和灵活性。
  3. 如果有循环依赖的问题,则不能使用构造函数注入,只能使用字段注入或者Setter方法注入,因为它们可以避免死锁的情况。
Spring是如何整合MyBatis将Mapper接口注册为Bean的原理?

1.首先MyBatis的Mapper接口核心是JDK动态代理

2.Spring会排除接口,无法注册到IOC容器中

3.MyBatis实现了BeanDefinitionRegistryPostProcessor可以动态注册BeanDefinition

4.需要自定义扫描器(继承Spring内部扫描器ClassPathBeanDefinitionScanner)重写排除接口的方法(isCandidateComponent)

5.但是接口虽然注册成了BeanDefinition但是无法实例化Bean,因为接口无法实例化

6.需要将BeanDefinition的BeanClass,替换成JDK动态代理的实例(偷天换日

7.MyBatis通过FactoryBean的工厂方法设计模式可以自由控制Bean的实例化过程,可以在getObject方法中创建JDK动态代理

SpringCloud有哪些常用组件,作用是什么

1.Eureka: 注册中心

2.Nacos: 注册中心、配置中心

3.Consul: 注册中心、配置中心

4.Spring Cloud Config: 配置中心

5.Feign/OpenFeign: RPC调用

6.Kong: 服务网关

7.Zuul: 服务网关

8.Spring Cloud Gateway: 服务网关

9.Ribbon: 负载均衡

10.Spring CLoud sleuth: 链路追踪

11.Zipkin: 链路追踪

12.Seata: 分布式事务

13.Dubbo: RPC调用

14.Sentinel: 服务熔断

15.Hystrix: 服务熔断

SpringCloud和Dubbo有哪些区别

SpringCloud是一个微服务框架,提供了微服务领域中的很多功能组件,Dubbo一开始是一个RPC调用框架,核心是解决服务调用间的问题,SpingCloud是一个大而全的框架,Dubbo则更侧重于服务调用,所以Dubbo所提供的功能没有SpingChoud全面,但是Dubbo的服务调用性能比Spring Cloud高,不过SpringCioud和Dubbo并不是对立的,是可以结合起来一起使用的。

Dubbo是如何完成服务导出的

1.首先Dubbo会将程序员所使用的@Dubboserrce注解或@Senice注解进行解析得到程序员所定义的服务参数,包括定义的服务名、服务接口、服务超时时间、服务协议等等,得到一个ServiceBean。

2.然后调用ServiceBean的export方法进行服务导出

3.然后将服务信息注册到注册中心,如果有多个协议,多个注册中心,那就将服务按单个协议,单个注册中心进行注册

4.将服务信息注册到注册中心后,还会绑定一些监听器,监听动态配置中心的变更

5.还会根据服务协议启动对应的Web服务器或网络框架,比如Tomcat、Netty等

Dubbo是如何完成服务引入的

1.当程序员使用@Reference注解来引入一个服务时,Dubbo会将注解和服务的信息解析出来,得到当前所引用的服务名、服务接口是什么

2.然后从注册中心进行查询服务信息,得到服务的提供者信息,并存在消费端的服务目录中

3.并绑定一些监听器用来监听动态配置中心的变更

4,然后根据查询得到的服务提供者信息生成一个服务接口的代理对象,并放入Spring容器中作为Bean

Dubbo支持哪些负载均衡策略

1.随机:从多个服务提供者随机选择一个来处理本次请求,调用量越大则分布越均匀,并支持按权重设置随机概率

2.轮询:依次选择服务提供者来处理请求,并支持按权重进行轮询,底层采用的是平滑加权轮询算法

3.最小活跃调用数:统计服务提供者当前正在处理的请求,下次请求过来则交给活跃数最小的服务器来处理

4.一致性哈希:相同参数的请求总是发到同一个服务提供者

常见的缓存淘汰算法

**FIFO(First In First Out,先进先出)**,根据缓存被存储的时间,离当前最远的数据优先被淘汰

**LRU(LeastRecentlyUsed,最近最少使用)**,根据最近被使用的时间,离当前最远的数据优先被淘汰

**LFU(LeastFrequentlyUsed,最不经常使用)**,在一段时间内,缓存数据被使用次数最少的会被淘汰

什么是中台?

所谓中台,就是将各个业务线中可以复用的一些功能抽取出来,剥离个性,提取共性,形成一些可复用的组件。

大体上,中台可以分为三类 业务中台、数据中台和技术中台。eg:大数据杀熟就是数据中台

中台跟DDD结合: DDD会通过限界上下文将系统拆分成一个一个的领域,而这种限界上下文,天生就成了中台之间的逻辑屏障。

DDD在技术与资源调度方面都能够给中台建设提供不错的指导。

DDD分为战略设计和战术设计。上层的战略设计能够很好的指导中台划分,下层的战术设计能够很好的指导微服务搭建。

epoll和poll的区别

1.select模型,使用的是数组来存储Socket连接文件描述符,容量是固定的,需要通过轮询来判断是否发生了IO事件

2.poll模型,使用的是链表来存储Socket连接文件描述符,容量是不固定的,同样需要通过轮询来判断是否发生了IO事件

3.epoll模型,epoll和poll是完全不同的,epoll是一种事件通知模型,当发生了IO事件时,应用程序才进行IO操作,不需要像poll模型那样主动去轮询

HashMap的扩容机制原理

1.7版本
1.先生成新数组

2.遍历老数组中的每个位置上的链表上的每个元素

3.取每个元素的key,并基于新数组长度,计算出每个元素在新数组中的下标,将元素添加到新数组中去

4.所有元素转移完了之后,将新数组赋值给HashMap对象的table属性

1.8版本
1.先生成新数组

2.遍历老数组中的每个位置上的链表或红黑树

3.如果是链表,则直接将链表中的每个元素重新计算下标,并添加到新数组中去

4.如果是红黑树,则先遍历红黑树,先计算出红黑树中每个元素对应在新数组中的下标位置
a.统计每个下标位置的元素个数
b.如果该位置下的元素个数超过了8,则生成一个新的红黑树,并将根节点的添加到新数组的对应位置
c.如果该位置下的元素个数没有超过8,那么则生成一个链表,并将链表的头节点添加到新数组的对应位置

5.所有元素转移完了之后,将新数组赋值给HashMap对象的table属性

ConcurrentHashMap的扩容机制

1.7版本

1.1.7版本的ConcurrentHashMap是基于Segment分段实现的

2.每个Segment相对于一个小型的HashMap

3.每个Segment内部会进行扩容,和HashMap的扩容逻辑类似

4.先生成新的数组,然后转移元素到新数组中

5.扩容的判断也是每个Segment内部单独判断的,判断是否超过阈值

1.8版本

1.1.8版本的ConcurrentHashMap不再基于Segment实现

2.当某个线程进行put时,如果发现ConcurrentHashMap正在进行扩容那么该线程一起进行扩容

3.如果某个线程put时,发现没有正在进行扩容,则将key-value添加到ConcurrentHashMap中,然后判断是否超过阈值,超过了则进行扩容

4.ConcurrentHashMap是支持多个线程同时扩容的

5.扩容之前也先生成一个新的数组

6.在转移元素时,先将原数组分组,将每组分给不同的线程来进行元素的转移,每个线程负责一组或多组的元素转移工作

HashMap的Put方法

HashMap的Put方法的大体流程:

1.根据Key通过哈希算法与与运算得出数组下标

2.如果数组下标位置元素为空,则将key和value封装为Entry对象**(JDK1.7中是Entry象,JDK1.8中是Node对象)**并放入该位置

3.如果数组下标位置元素不为空,则要分情况讨论

a.如果是JDK1.7,则先判断是否需要扩容,如果要扩容就进行扩容,如果不用扩容就生成Entry对象,并使用头插法添加到当前位置的链表中

b.如果是JDK1.8,则会先判断当前位置上的Node的类型,看是红黑树Node,还是链表Node

  1. 如果是红黑树Node,则将key和value封装为一个红黑树节点并添加到红树中去,在这个过程中会判断红黑树中是否存在当前key,如果存在则更新value
  2. 如果此位置上的Node对象是链表节点,则将key和value封装为一个链表Node并通过尾插法插入到链表的最后位置去,因为是尾插法,所以需要遍历链表,在
    遍历链表的过程中会判断是否存在当前key,如果存在则更新value,当遍历完链表后,将新链表Node插入到链表中,插入到链表后,会看当前链表的节点个
    数,如果大于等于8,那么则会将该链表转成红黑树
  3. 将key和value封装为Node插入到链表或红黑树中后,再判断是否需要进行扩容,如果需要就扩容,如果不需要就结束PUT方法
接口优化

后端优化

1.缓存机制

2.并发调用

3.同步接口异步化

4.避免大事务

5.优化日志记录

数据库

1.数据库查询优化

2.表设计冗余数据

3.使用连接池管理数据库连接

4.使用数据压缩技术

5.加机器,或者换成合适的数据库

消息队列的作用

1.解耦:使用消息队列来作为两个系统之间的通讯方式,两个系统不需要相互依赖了

2.异步:系统A给消息队列发送完消息之后,就可以继续做其他事情了

3.流量削峰:如果使用消息队列的方式来调用某个系统,那么消息将在队列中排队,由消费者自己控制消费速度

如何进行消息队列的选型?

Kafka:

  • 优点:吞吐量非常大,性能非常好,集群高可用
  • 缺点:会丢数据,功能比较单一
  • 使用场景:日志分析、大数据采集

RabbitMQ:

  • **优点: **消息可靠性高,功能全面
  • **缺点: **吞吐量比较低,消息积累会严重影响性能。erlang语言不好定制
  • **使用场景: **小规模场景

RocketMQ:

  • 优点: 高吞吐、高性能、高可用,功能非常全面
  • 缺点: 开源版功能不如云上商业版。官方文档和周边生态还不够成熟。客户端只支持java
  • 使用场景: 几乎是全场景
消息队列如何保证消息可靠传输

消息可靠传输代表了两层意思,既不能多也不能少。

1.为了保证消息不多,也就是消息不能重复,也就是生产者不能重复生产消息,或者消费者不能重复消费消息

2.首先要确保消息不多发,这个不常出现,也比较难控制,因为如果出现了多发,很大的原因是生产者自己的原因,如果要避免出现问题,就需要在消费端做控制

3.要避免不重复消费,最保险的机制就是消费者实现幂等性,保证就算重复消费,也不会有问题,通过幂等性,也能解决生产者重复发送消息的问题

4.消息不能少,意思就是消息不能丢失,生产者发送的消息,消费者一定要能消费到,对于这个问题,就要考虑两个方面

5、生产者发送消息时,要确认broker确实收到并持久化了这条消息,比如RabbityO的confirm机制,Kafka的ack机制都可以保证生产者能正确的将消息发送给broke

6.broker要等待消费者真正确认消费到了消息时才删除掉消息,这里通常就是消费端ac机制,消费者接收到一条消息后,如果确认没问题了,就可以给broker发送一个ack,broker接收到ack后才会删除消息

死信队列是什么?延时队列是什么?

1.死信队列也是一个消息队列,它是用来存放那些没有成功消费的消息的,通常可以用来作为消息重试
2.延时队列就是用来存放需要在指定时间被处理的元素的队列,通常可以用来处理一些具有过期性操作的业务,比如十分钟内未支付则取消订单

RocketMQ的事务消息是如何实现的

1.生产者订单系统先发送一条half消息到Broker,half消息对消费者而言是不可见的

2.再创建订单,根据创建订单成功与否,向Broker发送commit或rollback

3.并且生产者订单系统还可以提供Broker回调接口,当Broker发现一段时间half消息没有收到任何操作命令,则会主动调此接口来查询订单是否创建成功

4.一旦half消息commit了,消费者库存系统就会来消费,如果消费成功,则消息销毁,分布式事务成功结束

5.如果消费失败,则根据重试策略进行重试,最后还失败则进入死信队列,等待进一步处理

浏览器发出一个请求到收到响应经历了哪些步骤?(简单描述)

1.浏览器解析用户输入的URL,生成一个HTTP格式的请求

2.先根据URL域名从本地hosts文件查找是否有映射IP,如果没有就将域名发送给电脑所配置的DNS进行域名解析,得到!P地址

3.浏览器通过操作系统将请求通过四层网络协议发送出去

4.途中可能会经过各种路由器、交换机,最终到达服务器

5.服务器收到请求后,根据请求所指定的端口,将请求传递给绑定了该端口的应用程序,比如8080被tomcat占用了

6.tomcat接收到请求数据后,按照http协议的格式进行解析,解析得到所要访问的servlet

7.然后servlet来处理这个请求,如果是SpringMVC中的Dispatcherserlet,那么则会找到对应的controller中的方法,并执行该方法得到结果

8.Tomcat得到响应结果后封装成HTTP响应的格式,并再次通过网络发送给浏览器所在的服务器

9.浏览器所在的服务器拿到结果后再传递给浏览器,浏览器则负责解析并渲染

RabbitMQ

RabbitMQ是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用Erlang语言编写的,而群集和故障转移是构建在开放电信平台框架上的。所有主要的编程语言均有与代理接口通讯的客户端库。

AMQP

AMQP 协议的 Erlang 的实现(当然 RabbitMQ 还支持 STOMP2MQTT3 等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。

RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。

AMQP的三层协议

Module Layer : 协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。

Session Layer : 中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。

TransportLayer : 最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。

AMQP模型的几大组件

  • 交换器 (Exchange):消息代理服务器中用于把消息路由到队列的组件。
  • 队列 (Queue):用来存储消息的数据结构,位于硬盘或内存中。
  • 绑定 (Binding):一套规则,告知交换器消息应该将消息投递给哪个队列。

什么是RoutingKey路由键

生产者将消息发送给交换器的时候,会指定一个RoutingKey,用来指定这个消息的路由规则,这个RoutingKey需要与交换器类型和绑定键(BindingKey)联合使用才能最终生效。

死信队列

DLX,全称为 Dead-Letter-Exchange,死信交换器,死信邮箱。当消息在一个队列中变成死信 (dead message) 之后,它能被重新被发送到另一个交换器中,这个交换器就是 DLX,绑定 DLX 的队列就称之为死信队列。

导致的死信的几种原因

  • 消息被拒(Basic.Reject /Basic.Nack) 且 requeue = false
  • 消息TTL过期。
  • 队列满了,无法再添加。
交换机类型(Exchange Types)

交换机是消息路由的核心组件,根据规则将消息分发到不同队列。RabbitMQ 支持以下 4 种标准交换机类型:

  1. Direct Exchange(直连交换机)
    • 路由规则:精确匹配 Routing Key
    • 场景:点对点通信(如订单处理)。
    • 示例
      生产者发送消息时指定 Routing Key="order.create",交换机将消息投递到绑定 Routing Key="order.create" 的队列。
  2. Topic Exchange(主题交换机)
    • 路由规则:模糊匹配 Routing Key(支持通配符 *#)。
    • 场景:多订阅者的消息分类(如日志分级)。
    • 示例
      Routing Key="order.*.paid" 可匹配 "order.123.paid""order.456.paid"
  3. Fanout Exchange(扇出交换机)
    • 路由规则:忽略 Routing Key,广播到所有绑定的队列。
    • 场景:发布订阅模式(如新闻推送)。
    • 示例
      发送一条消息,所有绑定到该交换机的队列都会收到副本。
  4. Headers Exchange(头交换机)
    • 路由规则:根据消息头(Headers)的键值对匹配,而非 Routing Key
    • 场景:复杂条件路由(如根据设备类型过滤消息)。
    • 示例
      绑定队列时设置匹配条件 {"x-match": "all", "device": "mobile"},仅接收 Headers 中包含 device: mobile 的消息。
  5. 默认交换机(Default Exchange)
    • 每个 RabbitMQ 实例自带一个无名默认交换机(类型为 Direct)。
    • 生产者可直接通过队列名作为 Routing Key 发送消息到指定队列。
队列类型(Queue Types)

RabbitMQ 3.8+ 引入了多种队列类型,针对不同场景优化:

  1. Classic Queue(经典队列)
    • 默认队列类型,依赖 Erlang 的分布式能力。
    • 缺点:集群中队列数据仅存在于单个节点(非镜像时),可靠性依赖镜像队列配置。
  2. Quorum Queue(仲裁队列)
    • 基于 Raft 协议实现,数据在集群多数节点同步后确认写入,保证高可用。
    • 场景:需要强一致性和故障恢复的场景(如金融交易)。
    • 特性:自动故障转移,支持消息持久化,但性能略低于 Classic。
  3. Stream Queue(流式队列)
    • 为高吞吐量、顺序消费设计的队列类型(类似 Kafka)。
    • 场景:日志处理、事件溯源等需顺序消费的场景。
    • 特性:支持消息分段存储、消费者按偏移量读取。

消息类型(Message Types)

严格来说,RabbitMQ 消息本身没有类型,但可通过属性控制行为:

  • 持久化消息
    消息标记为 delivery_mode=2,配合持久化队列,确保重启后不丢失。
  • 临时消息
    默认 delivery_mode=1,仅存于内存,重启后丢失。

其他类型
  1. 插件扩展类型
    • 延迟消息:通过插件(rabbitmq-delayed-message-exchange)实现延迟交换机。
    • 其他协议:如 MQTT、STOMP 等协议对应的消息类型。

总结
类型分类 核心类型 典型场景
交换机(Exchange) Direct, Topic, Fanout, Headers 精确路由、模糊匹配、广播、复杂条件路由
队列(Queue) Classic, Quorum, Stream 通用场景、强一致性、顺序流处理
消息属性 持久化 vs 非持久化 可靠性要求高低

根据业务需求选择合适类型

  • 需要灵活路由 ➜ Topic Exchange
  • 要求高可用 ➜ Quorum Queue
  • 顺序消费流数据 ➜ Stream Queue

Redis

Redis是单线程吗?

Redis单线程指的是**{接受客户端请求->解析请求->进行数据读写扽操作->发送数据给客户端}这个过程是由一个线程(主线程)**来完成的,这是我们常说Redis是单线程的原因

但是,Redis程序并不是单线程的,Redis在启动的时候,是会启动后台线程 (BIO)

2.6版本,会启动2个后台线程,分别处理关闭文件,AOF刷盘这两个任务

4.0版本之后,新增了一个新的后台线程,用来异步释放Redis内存,也就是lazyfree线程

6.0版本之后,采用了多个I/O线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络I/O的处理上。但是对于命令的执行,Redis仍然使用单线程来处理。

Redis 为什么这么快?

Redis 内部做了非常多的性能优化,比较重要的有下面 3 点

  1. Redis 基于内存,内存的访问速度比磁盘快很多;
  2. Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到);
  3. Redis 内置了多种优化过后的数据类型/结构实现,性能非常高。
  4. Redis 通信协议实现简单且解析高效。
Redis持久化

方式:

AOF日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里

RDB快照:将某一时刻的内存数据,以二进制的方式写入磁盘

混合持久化方式:Redis 4.0 新增的方式,集成了AOF和RBD的优点

Redis集群

如何是实现高可用

主从复制

主从复制时Redis高可用服务的最基础的保证,实现方案就是将从前的一台Redis服务器,同步数据到多台从Redis服务器上,即一主多从的模式,且从服务器之间采用的时(读写分离)的方式

注意,主从服务器之间的命令复制是异步进行的

哨兵模式

在使用Redis主从服务的时候,会有一个问题,就是当Redis的主从服务器出现故障宕机时,需要手动进行恢复

为了解决 这个问题,Redis增加了哨兵模式(Redis Sentinel),因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能

切片集群模式

当Redis缓存数据量大到一台服务器无法缓存时,就需要使用Redis切片集群(Redis Cluster)方案,它将数据分布在不同的服务器上吗,以此来降低系统对单主节点的依赖,从而提高Redis服务的读写性能

Redis过期删除与内存淘汰

Redis使用的过期策略策略是(惰性删除+定时删除)这两种策略配合使用

1.惰性删除

不主动删除过期键,每次从数据库访问key时,都检测key是否过期,如果过期则删除该key

优点

只会使用很少的系统资源,对CUP时间友好

缺点

造成一定的内存空间浪费,对内存不友好

2.定期删除

每一段时间(随机)从数据库中取出一定数量的key进行检查,并删除其中的过期key

优点

通过限制删除操作执行的时长和频率,来减少删除操作对CPU的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用

缺点

难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对CPU不友好,如果执行的太少,那又和惰性删除一样了,过期key占用的内存不会及时得到释放

Redis缓存设计

如何避免缓存雪崩?

大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户的用户请求,都无法在Redis中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩

解决方案

将缓存失效时间随机打散:我们可以在原有的失效时间基础上增加一个随机值(比如1到10分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率

设置缓存不过期:我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题

如何避免缓存击穿?

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题

可以认为缓存击穿时缓存雪崩的一个子集,应对缓存击穿可以采取前面两种方案

1.互斥锁方案(Redis中使用setNX方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值

2.不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期时,提前通知后台线程更新缓存以及重新设置过期时间

如何避免缓存穿透?

当用户访问的数据,既不在缓存中,又不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求,那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题

缓存穿透的发生 一般有这两种情况:

1.业务误操作:缓存中的数据和数据库中的数据都被误删除了,所有导致缓存和数据库中都没有数据

2.黑客恶意攻击:故意大量访问某些读取不存在数据的业务

解决方案

非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。

设置空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。

使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

缓存更新策略

常见的缓存更新策略共有3种:

1.Cache Aside(旁路缓存)策略

2.Read/Write Through(读写/写穿)策略

3.Write Back(写回)策略

实际开发中,Redis和MySQL的更新策略用的是Cache Aside,另外两种策略应用不了

Cache Aside(旁路缓存)策略

可以细分为读策略写策略

写策略步骤:

先更新数据库中的数据,再删除缓存中的数据

读策略步骤

如何读取的数据命中了缓存,则直接返回数据

如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户

Redis实战
Redis 除了做缓存,还能做什么?
  • 分布式锁 :通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。
  • 限流 :一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。
  • 消息队列 :Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
  • 延时队列 :Redisson 内置了延时队列(基于 Sorted Set 实现的)。
  • 分布式 Session :利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
  • 复杂业务场景 :通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜。
Redis 如何实现延迟队列?

延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:

1.在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消

2.打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单

3.点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单

在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。

使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。

Redis管道有什么用?

管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个Redis命令,从而提高整个交互性能

使用管道技术可以解决多个命令执行时的网络等待,它是吧多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。

但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞

要注意的是,管道技术本质上是客户端提供的功能,而非Redis服务器的功能

如何用Redis实现分布式锁的?

分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源同一时刻只能被一个应用所使用

Redis的SET命令有个NX参数可以实现(key不存在才插入),所以可以用它来实现分布式锁:

1.如果key不存在,则显示插入成功,可以用来表示加锁成功

2.如果key存在,则会显示插入失败,可以用来表示加锁失败

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件

1.加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁

2.锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间

3.锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端

满足这三个条件的分布式命令如下:

1
set lock_key unique_value NX PX 10000

lock_key 就是 key 键
unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作
NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作
PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁

而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

基于 Redis 实现分布式锁的优点:
1.性能高效(这是选择缓存实现分布式锁最核心的出发点)。

2.实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。

3.避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。

基于 Redis 实现分布式锁的缺点:
1.超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。

  • 那么如何合理设置超时时间呢?我们可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。

2.Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。

Redis 如何解决集群情况下分布式锁的可靠性?

为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)

它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。

这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

Redlock 算法加锁三个过程:

第一步是,客户端获取当前时间(t1)。

第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:

  1. 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。

  2. 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时 时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。

第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):

条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;

条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。

加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

Redis数据结构的应用场景

Redis的数据结构:

1.字符串:可以用来做最简单的数据,可以缓存某个简单的字符串,也可以缓存某个ison格式的字符串,Redis分布式锁的实现就利用了这种数据结构,还包括可以实现计数器、Session共享、分布式ID

2.哈希表:可以用来存储一些key-value对,更适合用来存储对象

3.**列表:**Redis的列表通过命令的组合,既可以当做栈,也可以当做队列来使用,可以用来缓存类似微信公众号、微博等消息流数据

4.集合:和列表类似,也可以存储多个元素,但是不能重复,集合可以进行交集、并集、差集操作,从而可以实现类似,我和某人共同关注的人、朋友圈点赞等功能

5.有序集合(ZSet):集合是无序的,有序集合可以设置顺序,可以用来实现排行榜功能

Redis hotkey(热 Key)

什么是 hotkey?

如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。

hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。

hotkey 有什么危害?

处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。

因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。

如何发现 hotkey?

1、使用 Redis 自带的 –hotkeys 参数来查找。

Redis 4.0.3 版本中新增了 hotkeys 参数,该参数能够返回所有 key 的被访问次数。

使用该方案的前提条件是 Redis Server 的 maxmemory-policy 参数设置为 LFU 算法。

Redis 中有两种 LFU 算法:

  1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。

  2. allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。

2、使用MONITOR 命令。

MONITOR 命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。

由于该命令对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR(生产环境中建议谨慎使用该命令)。

MySQL

MySQL执行流程

MySQL的架构共分为两层:Server层和存储引擎层

Server 层负责建立连接、分析和执行 SQL。MySQL 大多数的核心功能模块都在这实现,主要包括连接器,查询缓存、解析器、预处理器、优化器、执行器等。另外,所有的内置函数(如日期、时间、数学和加密函数等)和所有跨存储引擎的功能(如存储过程、触发器、视图等)都在 Server 层实现。

存储引擎层负责数据的存储和提取。支持 InnoDB、MyISAM、Memory 等多个存储引擎,不同的存储引擎共用一个 Server 层。现在最常用的存储引擎是 InnoDB,从 MySQL 5.5 版本开始, InnoDB 成为了 MySQL 的默认存储引擎。我们常说的索引数据结构,就是由存储引擎层实现的,不同的存储引擎支持的索引类型也不相同,比如 InnoDB 支持索引类型是 B+树 ,且是默认使用,也就是说在数据表中创建的主键索引和二级索引默认使用的是 B+ 树索引。

第一步:连接器

1.连接的过程需要先经过TCP三次握手,因为MySQL是基于TCP协议进行传输的。

2.校验客户端的用户和密码,如果用户名或密码不对,则会报错

3.如果用户名和密码都对,会读取该用户的权限,然后后面的权限逻辑判断都基于此时读取的权限

第二步:查询缓存

连接器得工作完成后,客户端就可以向 MySQL 服务发送 SQL 语句了,MySQL 服务收到 SQL 语句后,就会解析出 SQL 语句的第一个字段,看看是什么类型的语句。

如果 SQL 是查询语句(select 语句),MySQL 就会先去查询缓存( Query Cache )里查找缓存数据,看看之前有没有执行过这一条命令,这个查询缓存是以 key-value 形式保存在内存中的,key 为 SQL 查询语句,value 为 SQL 语句查询的结果。

如果查询的语句命中查询缓存,那么就会直接返回 value 给客户端。如果查询的语句没有命中查询缓存中,那么就要往下继续执行,等执行完后,查询的结果就会被存入查询缓存中。

这么看,查询缓存还挺有用,但是其实查询缓存挺鸡肋的。

对于更新比较频繁的表,查询缓存的命中率很低的,因为只要一个表有更新操作,那么这个表的查询缓存就会被清空。如果刚缓存了一个查询结果很大的数据,还没被使用的时候,刚好这个表有更新操作,查询缓冲就被清空了,相当于缓存了个寂寞。

所以,MySQL 8.0 版本直接将查询缓存删掉了,也就是说 MySQL 8.0 开始,执行一条 SQL 查询语句,不会再走到查询缓存这个阶段了。

对于 MySQL 8.0 之前的版本,如果想关闭查询缓存,我们可以通过将参数 query_cache_type 设置成 DEMAND

第三步:解析SQL

在正式执行SQL查询语句之前,MySQL会先对SQL语句做解析,这个工作交由(解析器)来完成

解析器

第一件事情,词法分析。MySQL 会根据你输入的字符串识别出关键字出来,例如,SQL语句 select username from userinfo,在分析之后,会得到4个Token,其中有2个Keyword,分别为select和from。

关键字 非关键字 关键字 非关键字
select username from userinfo

第二件事情,语法分析。根据词法分析的结果,语法解析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法,如果没问题就会构建出 SQL 语法树,这样方便后面模块获取 SQL 类型、表名、字段名、 where 条件等等。

如果我们输入的SQL语句语法不对,就会在解析器这个阶段报错。

第四步:执行SQL

经过解析器后,接着就要进入执行 SQL 查询语句的流程了,每条SELECT 查询语句流程主要可以分为下面这三个阶段:

1.prepare 阶段,也就是预处理阶段;
2.optimize 阶段,也就是优化阶段;
3.execute 阶段,也就是执行阶段;

预处理器

1.检查 SQL 查询语句中的表或者字段是否存在

2.将 select * 中的 * 符号,扩展为表上的所有列

优化器

经过预处理阶段后,还需要为 SQL 查询语句先制定一个执行计划,这个工作交由「优化器」来完成的。

优化器主要负责将 SQL 查询语句的执行方案确定下来,比如在表里面有多个索引的时候,优化器会基于查询成本的考虑,来决定选择使用哪个索引。

例如查询语句(select * from product where id = 1),就是选择使用主键索引。

要想知道优化器选择了哪个索引,我们可以在查询语句最前面加个 explain 命令,这样就会输出这条 SQL 语句的执行计划,然后执行计划中的 key 就表示执行过程中使用了哪个索引。

explain的type字段(访问类型)的10个状态(从左到右,越靠左的越优秀)

NULL system const eq_ref ref ref_or_null index_merge range index ALL

执行器

经历完优化器后,就确定了执行方案,接下来 MySQL 就真正开始执行语句了,这个工作是由「执行器」完成的。在执行的过程中,执行器就会和存储引擎交互了,交互是以记录为单位的。

接下来,用三种方式执行过程,说一下执行器和存储引擎的交互过程

1.主键索引查询

以下面这个查询语句为例,看看执行器是怎么工作的。

1
select * from product where id = 1;

这条查询语句的查询条件用到了主键索引,而且是等值查询,同时主键 id 是唯一,不会有 id 相同的记录,所以优化器决定选用访问类型为 const 进行查询,也就是使用主键索引查询一条记录,那么执行器与存储引擎的执行流程是这样的:

  1. 执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为 InnoDB 引擎索引查询的接口,把条件 id = 1 交给存储引擎,让存储引擎定位符合条件的第一条记录。
  2. 存储引擎通过主键索引的 B+ 树结构定位到 id = 1的第一条记录,如果记录是不存在的,就会向执行器上报记录找不到的错误,然后查询结束。如果记录是存在的,就会将记录返回给执行器;
  3. 执行器从存储引擎读到记录后,接着判断记录是否符合查询条件,如果符合则发送给客户端,如果不符合则跳过该记录。
  4. 执行器查询的过程是一个 while 循环,所以还会再查一次,但是这次因为不是第一次查询了,所以会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为一个永远返回 - 1 的函数,所以当调用该函数的时候,执行器就退出循环,也就是结束查询了。

2.全表扫描

举个全表扫描的例子:

1
select * from product where name = 'iphone';

这条查询语句的查询条件没有用到索引,所以优化器决定选用访问类型为 ALL 进行查询,也就是全表扫描的方式查询,那么这时执行器与存储引擎的执行流程是这样的:

  1. 执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 all,这个函数指针被指向为 InnoDB 引擎全扫描的接口,让存储引擎读取表中的第一条记录;
  2. 执行器会判断读到的这条记录的 name 是不是 iphone,如果不是则跳过;如果是则将记录发给客户的(是的没错,Server 层每从存储引擎读到一条记录就会发送给客户端,之所以客户端显示的时候是直接显示所有记录的,是因为客户端是等查询语句查询完成后,才会显示出所有的记录)。
  3. 执行器查询的过程是一个 while 循环,所以还会再查一次,会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 all,read_record 函数指针指向的还是 InnoDB 引擎全扫描的接口,所以接着向存储引擎层要求继续读刚才那条记录的下一条记录,存储引擎把下一条记录取出后就将其返回给执行器(Server层),执行器继续判断条件,不符合查询条件即跳过该记录,否则发送到客户端;
  4. 一直重复上述过程,直到存储引擎把表中的所有记录读完,然后向执行器(Server层) 返回了读取完毕的信息;
  5. 执行器收到存储引擎报告的查询完毕的信息,退出循环,停止查询。

3.索引下推

索引下推能够减少二级索引在查询时的回表操作,提高查询的效率,因为它将Server层部分负责的事情,交给存储引擎层去处理了

举一个具体的例子,age 和 height字段建立了联合索引(age,height):

1
select * from Students where age > 20 and height = 180;

联合索引当遇到范围查询 (>、<) 就会停止匹配,也就是 age 字段能用到联合索引,但是 height字段则无法利用到索引。

不使用索引下推(MySQL 5.6 之前的版本)时,执行器与存储引擎的执行流程是这样的:

  1. Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录
  2. 存储引擎根据二级索引的 B+ 树快速定位到这条记录后,获取主键值,然后进行回表操作,将完整的记录返回给 Server 层
  3. Server 层在判断该记录的 height 是否等于 180,如果成立则将其发送给客户端;否则跳过该记录
  4. 接着,继续向存储引擎索要下一条记录,存储引擎在二级索引定位到记录后,获取主键值,然后回表操作,将完整的记录返回给 Server 层
  5. 如此往复,直到存储引擎把表中的所有记录读完。

可以看到,没有索引下推的时候,每查询到一条二级索引记录,都要进行回表操作,然后将记录返回给 Server,接着 Server 再判断该记录的 height是否等于 180。

而使用索引下推后,判断记录的 height是否等于 180 的工作交给了存储引擎层,过程如下 :

  1. Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录
  2. 存储引擎定位到二级索引后,先不执行回表操作,而是先判断一下该索引中包含的列(height列)的条件(height是否等于 180)是否成立。如果条件不成立,则直接跳过该二级索引。如果成立,则执行回表操作,将完成记录返回给 Server 层。
  3. Server 层在判断其他的查询条件(本次查询没有其他条件)是否成立,如果成立则将其发送给客户端;否则跳过该记录,然后向存储引擎索要下一条记录。
  4. 如此往复,直到存储引擎把表中的所有记录读完。

可以看到,使用了索引下推后,虽然 height 列无法使用到联合索引,但是因为它包含在联合索引(age,height)里,所以直接在存储引擎过滤出满足 height= 180的记录后,才去执行回表操作获取整个记录。相比于没有使用索引下推,节省了很多回表操作。

当你发现执行计划里的 Extr 部分显示了 “Using index condition”,说明使用了索引下推。

总结

  • 连接器:建立连接,管理连接、校验用户身份

  • 查询缓存:查询语句如果命中查询缓存则直接返回,否则继续往下执行。MySQL 8.0 已删除该模块

  • 解析 SQL:通过解析器对 SQL 查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段、语句类型

  • 执行 SQL:执行 SQL 共有三个阶段:

    • 预处理阶段:检查表或字段是否存在;将 select * 中的 * 符号扩展为表上的所有列。

    • 优化阶段:基于查询成本的考虑, 选择查询成本最小的执行计划

    • 执行阶段:根据执行计划执行 SQL 查询语句,从存储引擎读取记录,返回给客户端;

MySQL三大日志(binlog、redo log和undo log)

MySQL 日志主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。其中,比较重要的还要属二进制日志 binlog(归档日志)和事务日志 redo log(重做日志)和 undo log(回滚日志)

1. Binlog(二进制日志)

Binlog 是 MySQL 的二进制日志,记录了对数据库执行的所有写操作(如 INSERTUPDATEDELETE 等),但不包括 SELECT 操作。Binlog 主要用于数据复制和数据恢复。

主要功能

  • 数据复制:在主从复制中,主库将 binlog 发送给从库,从库重放这些日志以实现数据同步。
  • 数据恢复:通过 binlog 可以恢复到某个时间点的数据状态。
  • 审计:记录所有对数据库的修改操作,便于审计和追踪。

特点

  • 逻辑日志:记录的是逻辑操作(如 SQL 语句),而不是物理数据页的变化。
  • 追加写入:binlog 是追加写入的,不会覆盖旧日志。
  • 可配置格式:支持 STATEMENTROWMIXED 三种格式:
    • STATEMENT:记录 SQL 语句。
    • ROW:记录每一行数据的变化。
    • MIXED:混合模式,根据情况选择 STATEMENTROW

使用场景

  • 主从复制。
  • 数据恢复。
  • 数据审计。

2. Redo Log(重做日志)

Redo Log 是 InnoDB 存储引擎特有的日志,用于保证事务的持久性。它记录的是物理数据页的修改,确保在数据库崩溃后能够恢复未写入磁盘的数据。

主要功能

  • 崩溃恢复:当数据库崩溃时,通过 redo log 可以将未写入磁盘的数据重新应用到数据页,确保数据不丢失。
  • 提高写性能:redo log 采用顺序写入的方式,比随机写入数据页更快,因此可以提升数据库的写性能。

特点

  • 物理日志 :记录的是数据页的物理修改,而不是逻辑操作。
  • 循环写入 :redo log 是固定大小的文件,采用循环写入的方式,写满后会覆盖旧日志。
  • Write-Ahead Logging (WAL) :在修改数据页之前,先写 redo log,确保日志先于数据页落盘。

使用场景

  • 保证事务的持久性。
  • 数据库崩溃恢复。

3. Undo Log(回滚日志)

Undo Log 也是 InnoDB 存储引擎特有的日志,用于保证事务的原子性和一致性。它记录的是事务执行前的数据状态,用于回滚事务和实现 MVCC(多版本并发控制)。

主要功能

  • 事务回滚:当事务需要回滚时,通过 undo log 可以将数据恢复到事务开始前的状态。
  • MVCC 支持:undo log 记录了数据的多个版本,支持并发事务读取历史数据,实现非阻塞读。

特点

  • 逻辑日志:记录的是逻辑操作的反向操作(如 INSERT 的反向操作是 DELETE)。
  • 多版本存储:undo log 存储了数据的多个版本,支持 MVCC。
  • 事务隔离:通过 undo log 可以实现事务的隔离级别(如 READ COMMITTEDREPEATABLE READ)。

使用场景

  • 事务回滚。
  • 实现 MVCC,支持并发事务的非阻塞读。

三大日志的关系与区别

特性 Binlog Redo Log Undo Log
日志类型 逻辑日志 物理日志 逻辑日志
作用 数据复制、恢复、审计 崩溃恢复、事务持久性 事务回滚、MVCC
存储引擎 所有存储引擎 InnoDB InnoDB
写入方式 追加写入 循环写入 追加写入
生命周期 长期保留 事务提交后可覆盖 事务提交后可能保留

总结

  • Binlog:用于数据复制、恢复和审计,记录逻辑操作。
  • Redo Log:用于崩溃恢复和事务持久性,记录物理数据页的修改。
  • Undo Log:用于事务回滚和 MVCC,记录事务执行前的数据状态。

这三大日志共同协作,确保 MySQL 数据库的事务性、持久性和一致性,是数据库高可用性和数据安全的重要保障。

MySQL日期类型选择建议

不要用字符串存储日期

和绝大部分对数据库不太了解的新手一样,我在大学的时候就这样干过,甚至认为这样是一个不错的表示日期的方法。毕竟简单直白,容易上手。

但是,这是不正确的做法,主要会有下面两个问题:

  1. 字符串占用的空间更大!
  2. 字符串存储的日期效率比较低(逐个字符进行比对),无法用日期相关的 API 进行计算和比较。

Datetime 和 Timestamp 之间的抉择

Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型,可以精确到秒。他们两者究竟该如何选择呢?

下面我们来简单对比一下二者。

时区信息

DateTime 类型是没有时区信息的(时区无关) ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。这样就会有什么问题呢?当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。

Timestamp 和时区有关。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样。

常见面试题

索引的分类

按照四个角度来分类索引

按「数据结构」分类:B+tree索引、Hash索引、Full-text索引。
按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)。
按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引。
按「字段个数」分类:单列索引、联合索引。

索引的基本原理

索引用来快速地寻找那些具有特定值的记录。如果没有索引,一般来说执行查询时遍历整张表

索引的原理:就是把无序的数据变成有序的查询

1.把创建了索引的列的内容进行排序

2.对排序结果生成倒排表

3.在倒排表内容上拼上数据地址链

4.在查询的时候,先拿到倒排表内容,再取出数据地址链,从而拿到具体数据

索引的优缺点

优点

  • 使用索引可以大大加快数据的检索速度(大大减少检索的数据量), 减少 IO 次数,这也是创建索引的最主要的原因。
  • 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。

缺点

  • 创建索引和维护索引需要耗费许多时间。当对表中的数据进行增删改的时候,如果数据有索引,那么索引也需要动态的修改,会降低 SQL 执行效率。
  • 索引需要使用物理文件存储,也会耗费一定空间。

但是,使用索引一定能提高查询性能吗?

大多数情况下,索引查询都是比全表扫描要快的。但是如果数据库的数据量不大,那么使用索引也不一定能够带来很大提升

索引覆盖

索引覆盖就是一个SQL在执行时,可以利用索引来快速查找,并且此SQL所要查询的字段在当前索引对应的字段中都包含了,那么就表示此SQL走完索引后不用回表了,所需要的字段都在当前索引的叶子节点上存在,可以直接作为结果返回了

B树和B+树的区别,为什么MySQL使用B+树

B树的特点:

1.节点排序

2.一个节点可以存多个元素,多个元素也排序了

B+树的特点:

1.拥有B树的特点

2.叶子节点之间有指针

3.非叶子节点上的元素在叶子节点上都冗余了,也就是叶子节点中存储了所以的元素,并且排好顺序

Mysql索引使用的是B+树,因为索引是用来加快查询的,而B+树通过对数据进行排序所以是可以提高查询速度的,然后通过一个节点中可以存储多个元素,从而可以使得B+树的高度不会太高,在Mysql中一个Innodb页就是一个B+树节点,一个Innodb页默认16kb,所以一般情况下一颗两层的B+树可以存2000万行左右的数据,然后通过利用B+树叶子节点存储了所有数据并且进行了排序,并且叶子节点之间有指针,可以很好的支持全表扫描,范围查找等SQL语句。

MyISAM和InnoDB的区别

MyISAM:

  • 不支持事务,但是每次查询都是原子的
  • 支持表级锁,即每次操作是对整个表加锁
  • 存储表的总行数
  • 个MYISAM表有三个文件**:索引文件、表结构文件、数据文件**
  • 采用非聚集索引,索引文件的数据域存储指向数据文件的指针。辅索引与主索引基本一致,但是辅索引不用保证唯一性

InnoDb:

  • 支持ACID的事务,支持事务的四种隔离级别
  • 支持行级锁及外键约束:因此可以支持写并发
  • 不存储总行数
  • 一个innoDb引擎存储在一个文件空间(共享表空间,表大小不受操作系统控制,一个表可能分布在多个文件里),也有可能为多个**(设置为独立表空,表大小受操作系统文件大小限制,一般为2G)**,受操作系统文件大小的限制
  • 主键索引采用聚集索引**(索引的数据域存储数据文件本身)**,辅索引的数据域存储主键的值;因此从辅索引查找数据,需要先通过辅索引找到主键值,再访问辅索引最好使用自增主键,防止插入数据时,为维持B+树结构,文件的大调整。
Innodb是如何实现事务的

Innodb通过Buffer Pool,LogBuffer,Redo Log,Undo Log来实现事务,

以一个update语句为例:

1.Innodb在收到一个update语句后,会先根据条件找到数据所在的页,
并将该页缓存在Buffer Pool中

2.执行update语句,修改Buffer Pool中的数据,也就是内存中的数据

3.针对update语句生成-个RedoLog对象,并存入LogBuffer中

4.针对update语句生成undolog日志,用于事务回滚

5.如果事务提交,那么则把RedoLog对象进行持久化,后续还有其他机制将Bufer Pool中所修改的数据页持久化到磁盘中

6.如果事务回滚,则利用undolog日志进行回滚

MySQL的rewriteBatchedStatements的使用场景
批量保存方式 数据量(条) 耗时(ms)
单条循环插入 1000 121011
mybatis-plus saveBatch 1000 59927
mybatis-plus saveBatch(添加rewtire参数) 1000 2589
手动拼接sql 1000 2275
jdbc executeBatch 1000 55663
jdbc executeBatch(添加rewtire参数) 1000 324

所以如果有使用 JDBC的 Batch 性能方面的需求,要将 rewriteBatchedStatements 设置为 true,这样能提高很多性能。

然后如果喜欢手动拼接 sql 要注意一次拼接的数量,分批处理。

MySQL慢查询该如何优化?

1.检查是否走了索引,如果没有则优化SQL利用索引

2.检查所利用的索引,是否是最优索引

3.检查所查字段是否都是必须的,是否查询了过多字段,查出了多余数据

4.检查表中数据是否过多,是否应该进行分库分表了

5.检查数据库实例所在机器的性能配置,是否太低,是否可以适当增加资源

MySQL锁有哪些,如何理解

按锁粒度分类:

1.行锁:锁某行数据,锁粒度最小,并发度高

2.表锁:锁整张表,锁粒度最大,并发度低

3.间隙锁:锁的是一个区间

还可以分为:

1.共享锁:也就是读锁,一个事务给某行数据加了读锁,其他事务也可以读,但是不能写

2.排它锁:也就是写锁,一个事务给某行数据加了写锁,其他事务不能读(不能加读锁),也不能写

还可以分为:

1.乐观锁:并不会真正的去锁某行记录,而是通过一个版本号来实现的

2.悲观锁:上面所的行锁、表锁等都是悲观锁

在事务的隔离级别实现中,就需要利用锁来解决幻读

什么是MVCC

MVCC (Multi-version concuuency contol,多版本并发控制) 指的就是在使用READ COMMITD,REPEATABLE READ这种隔离级别的事务在执行营通的SELECT操作时访问记录的版本链的过程。可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITD、REPEATABLE READ这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成个ReadView,之后的查询操作都重复使用这个ReadView就好了

最左前缀原则

当一个SQL想要利用索引是,就一定要提供该索引所对应的字段中最左边的字段,也就是排在最前面的字段,比如针对 a,b,c 三个字段建立了一个联合索引,那么在写一sql时就一定要提供a字段的条件,这样才能用到联合索引,这是由于在建立 a,b,c 三个字段的联合索引时,底层的B+树是按照 a,b,c 三个字段从左往右去比较大小进行排序的,所以如果想要利用 B+树 进行快速查找也得符合这个规则

算法笔记

链表

判断回文

1.和出栈比较

2.快慢指针(右边进栈和左边比较)

3.空间O(1)快慢指针把中赋为null右边指针反转,头尾比较等于null退出,把链表复原

栈方法简单(笔试用)

改原链表的方法就需要注意边界了(面试用)

判断是否有环

快慢指针相遇有环(在环上相遇)

快指针为null无环

快指针从头再来慢指针还在原地快慢指针每次走一步,最终会在第一个入环节点处相遇

布隆过滤器

样本量

失误率

判断一个32位的数是否为2的幂(用位运算)

x&(x-1)==0

判断一个32位的数是否为4的幂(用位运算)

1.是2的幂

2.x&(0x55555555)!=0 //0x55555555==(010101…01)

计划搜索缓存

跳过重复条件

二叉树序列化和反序列化

层次遍历序列化
层次遍历反序列化
二叉树的宽度优先遍历(用队列)
最大宽度
最大宽度不用Map
给二叉树中的某个节点,返回该节点的后继节点

二叉树的结构

二叉树的递归套路

请把一段纸条竖着放在桌子上,然后从纸条的下边向上方对折1次,压出折痕后展开。此时折痕是凹下去的,即折痕突起的方向指向纸条的背面。如果从纸条的下边向上方连续对折2次,压出折痕后展开,此时有三条折痕,从上到下依次是下折痕、下折痕和上折痕。给定一个输入参数N,代表纸条都从下边向上方连续对折N次。请从上到下打印所有折痕的方向。例如:N=1时,打印: down N=2时,打印: down down up

思路:用递归模拟了树(树没有建出来)

左右高度相差大于1

给定一颗二叉树的头节点head,任何两个节点之间都存在距离,返回整颗二叉树的最大距离
给定一颗二叉树的头节点head,返回这颗二叉树中最大的二叉树搜索子树的头节点
派对的最大快乐值

打表找规律(暴力找规则)

牛羊吃草

规律解法

连续正数和的数

规律解法

矩阵处理技巧

1.zigzag打印矩阵

1 2 3

4 5 6 –> 打印输出 124753689

7 8 9

2.转圈打印矩阵
原地旋转正方形矩阵

贪心算法求解的标准过程

解题套路
宣讲次数最多

结构

暴力解

第二种

排序(根据谁的时间短排序)

最少灯

暴力法

贪心

分金条

贪心

最大盈利
并查集

简写if

分类用户(代码与上面的通用)

基本结构
宽度优先遍历
深度优先遍历
拓扑排序
最小生成树(Kruskal)利用并查集
Prim
Dijkstra(迪特拉)

改进

源码

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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
public class Dijkstra {

public static HashMap<Node, Integer> dijkstra1(Node from) {
HashMap<Node, Integer> distanceMap = new HashMap<>();
distanceMap.put(from, 0);
// 打过对号的点
HashSet<Node> selectedNodes = new HashSet<>();
Node minNode = getMinDistanceAndUnselectedNode(distanceMap, selectedNodes);
while (minNode != null) {
// 原始点 -> minNode(跳转点) 最小距离distance
int distance = distanceMap.get(minNode);
for (Edge edge : minNode.edges) {
Node toNode = edge.to;
if (!distanceMap.containsKey(toNode)) {
distanceMap.put(toNode, distance + edge.weight);
} else { // toNode
distanceMap.put(edge.to, Math.min(distanceMap.get(toNode), distance + edge.weight));
}
}
selectedNodes.add(minNode);
minNode = getMinDistanceAndUnselectedNode(distanceMap, selectedNodes);
}
return distanceMap;
}

public static Node getMinDistanceAndUnselectedNode(HashMap<Node, Integer> distanceMap, HashSet<Node> touchedNodes) {
Node minNode = null;
int minDistance = Integer.MAX_VALUE;
for (Entry<Node, Integer> entry : distanceMap.entrySet()) {
Node node = entry.getKey();
int distance = entry.getValue();
if (!touchedNodes.contains(node) && distance < minDistance) {
minNode = node;
minDistance = distance;
}
}
return minNode;
}

public static class NodeRecord {
public Node node;
public int distance;

public NodeRecord(Node node, int distance) {
this.node = node;
this.distance = distance;
}
}

public static class NodeHeap {
private Node[] nodes; // 实际的堆结构
// key 某一个node, value 上面堆中的位置
private HashMap<Node, Integer> heapIndexMap;
// key 某一个节点, value 从源节点出发到该节点的目前最小距离
private HashMap<Node, Integer> distanceMap;
private int size; // 堆上有多少个点

public NodeHeap(int size) {
nodes = new Node[size];
heapIndexMap = new HashMap<>();
distanceMap = new HashMap<>();
size = 0;
}

public boolean isEmpty() {
return size == 0;
}

// 有一个点叫node,现在发现了一个从源节点出发到达node的距离为distance
// 判断要不要更新,如果需要的话,就更新
public void addOrUpdateOrIgnore(Node node, int distance) {
if (inHeap(node)) {
distanceMap.put(node, Math.min(distanceMap.get(node), distance));
insertHeapify(heapIndexMap.get(node));
}
if (!isEntered(node)) {
nodes[size] = node;
heapIndexMap.put(node, size);
distanceMap.put(node, distance);
insertHeapify(size++);
}
}

public NodeRecord pop() {
NodeRecord nodeRecord = new NodeRecord(nodes[0], distanceMap.get(nodes[0]));
swap(0, size - 1);
heapIndexMap.put(nodes[size - 1], -1);
distanceMap.remove(nodes[size - 1]);
// free C++同学还要把原本堆顶节点析构,对java同学不必
nodes[size - 1] = null;
heapify(0, --size);
return nodeRecord;
}

private void insertHeapify(int index) {
while (distanceMap.get(nodes[index]) < distanceMap.get(nodes[(index - 1) / 2])) {
swap(index, (index - 1) / 2);
index = (index - 1) / 2;
}
}

private void heapify(int index, int size) {
int left = index * 2 + 1;
while (left < size) {
int smallest = left + 1 < size && distanceMap.get(nodes[left + 1]) < distanceMap.get(nodes[left])
? left + 1
: left;
smallest = distanceMap.get(nodes[smallest]) < distanceMap.get(nodes[index]) ? smallest : index;
if (smallest == index) {
break;
}
swap(smallest, index);
index = smallest;
left = index * 2 + 1;
}
}

private boolean isEntered(Node node) {
return heapIndexMap.containsKey(node);
}

private boolean inHeap(Node node) {
return isEntered(node) && heapIndexMap.get(node) != -1;
}

private void swap(int index1, int index2) {
heapIndexMap.put(nodes[index1], index2);
heapIndexMap.put(nodes[index2], index1);
Node tmp = nodes[index1];
nodes[index1] = nodes[index2];
nodes[index2] = tmp;
}
}

// 改进后的dijkstra算法
// 从head出发,所有head能到达的节点,生成到达每个节点的最小路径记录并返回
public static HashMap<Node, Integer> dijkstra2(Node head, int size) {
NodeHeap nodeHeap = new NodeHeap(size);
nodeHeap.addOrUpdateOrIgnore(head, 0);
HashMap<Node, Integer> result = new HashMap<>();
while (!nodeHeap.isEmpty()) {
NodeRecord record = nodeHeap.pop();
Node cur = record.node;
int distance = record.distance;
for (Edge edge : cur.edges) {
nodeHeap.addOrUpdateOrIgnore(edge.to, edge.weight + distance);
}
result.put(cur, distance);
}
return result;
}

}

暴力递归

什么暴力递归可以继续优化
汉诺塔

递归

非递归

逆序栈
字符串的子序列
无重复子序列
字符串全排列
无重复全排列(分支限界)
数字字符转化
背包问题

另一种

选牌
n皇后

利用位移

货币凑整
1
2
3
4
5
6
7
8
9
10
11
// arr[index....] 所有的面值,每一个面值都可以任意选择张数,组成正好rest这么多钱,方法数多少?
public static int process(int[] arr, int index, int rest) {
if (index == arr.length) { // 没钱了
return rest == 0 ? 1 : 0;
}
int ways = 0;
for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
ways += process(arr, index + 1, rest - (zhang * arr[index]));
}
return ways;
}

递归改动态规划(题目同上面相同)

步骤:暴力递归(重复解)->可变参数(不讲究组织)->记忆化搜索(精细化组织)->经典动态规划
回到原始位置

2

背包问题

2

数字字符转化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 从右往左的动态规划
// 就是上面方法的动态规划版本
// dp[i]表示:str[i...]有多少种转化方式
public static int dp1(String s) {
if (s == null || s.length() == 0) {
return 0;
}
char[] str = s.toCharArray();
int N = str.length;
int[] dp = new int[N + 1];
dp[N] = 1;
for (int i = N - 1; i >= 0; i--) {
if (str[i] != '0') {
int ways = dp[i + 1];
if (i + 1 < str.length && (str[i] - '0') * 10 + str[i + 1] - '0' < 27) {
ways += dp[i + 2];
}
dp[i] = ways;
}
}
return dp[0];
}

// 从左往右的动态规划
// dp[i]表示:str[0...i]有多少种转化方式
public static int dp2(String s) {
if (s == null || s.length() == 0) {
return 0;
}
char[] str = s.toCharArray();
int N = str.length;
if (str[0] == '0') {
return 0;
}
int[] dp = new int[N];
dp[0] = 1;
for (int i = 1; i < N; i++) {
if (str[i] == '0') {
// 如果此时str[i]=='0',那么他是一定要拉前一个字符(i-1的字符)一起拼的,
// 那么就要求前一个字符,不能也是‘0’,否则拼不了。
// 前一个字符不是‘0’就够了嘛?不够,还得要求拼完了要么是10,要么是20,如果更大的话,拼不了。
// 这就够了嘛?还不够,你们拼完了,还得要求str[0...i-2]真的可以被分解!
// 如果str[0...i-2]都不存在分解方案,那i和i-1拼成了也不行,因为之前的搞定不了。
if (str[i - 1] == '0' || str[i - 1] > '2' || (i - 2 >= 0 && dp[i - 2] == 0)) {
return 0;
} else {
dp[i] = i - 2 >= 0 ? dp[i - 2] : 1;
}
} else {
dp[i] = dp[i - 1];
if (str[i - 1] != '0' && (str[i - 1] - '0') * 10 + str[i] - '0' <= 26) {
dp[i] += i - 2 >= 0 ? dp[i - 2] : 1;
}
}
}
return dp[N - 1];
}
选牌
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static int win(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int N = arr.length;
int[][] fmap = new int[N][N];
int[][] gmap = new int[N][N];
for (int i = 0; i < N; i++) {
fmap[i][i] = arr[i];
}
for (int startCol = 1; startCol < N; startCol++) {
int L = 0;
int R = startCol;
while (R < N) {
fmap[L][R] = Math.max(arr[L] + gmap[L + 1][R], arr[R] + gmap[L][R - 1]);
gmap[L][R] = Math.min(fmap[L + 1][R], fmap[L][R - 1]);
L++;
R++;
}
}
return Math.max(fmap[0][N - 1], gmap[0][N - 1]);
}
货币凑整
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// arr[index....] 所有的面值,每一个面值都可以任意选择张数,组成正好rest这么多钱,方法数多少?
public static int dp1(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
int ways = 0;
for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
ways += dp[index + 1][rest - (zhang * arr[index])];
}
dp[index][rest] = ways;
}
}
return dp[0][aim];
}

public static int dp2(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest - arr[index] >= 0) {
dp[index][rest] += dp[index][rest - arr[index]];
}
}
}
return dp[0][aim];
}
贴纸字符串
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
public static int minStickers2(String[] stickers, String target) {
int N = stickers.length;
// 关键优化(用词频表替代贴纸数组)
int[][] counts = new int[N][26];
for (int i = 0; i < N; i++) {
char[] str = stickers[i].toCharArray();
for (char cha : str) {
counts[i][cha - 'a']++;
}
}
int ans = process2(counts, target);
return ans == Integer.MAX_VALUE ? -1 : ans;
}

// stickers[i] 数组,当初i号贴纸的字符统计 int[][] stickers -> 所有的贴纸
// 每一种贴纸都有无穷张
// 返回搞定target的最少张数
// 最少张数
public static int process2(int[][] stickers, String t) {
if (t.length() == 0) {
return 0;
}
// target做出词频统计
// target aabbc 2 2 1..
// 0 1 2..
char[] target = t.toCharArray();
int[] tcounts = new int[26];
for (char cha : target) {
tcounts[cha - 'a']++;
}
int N = stickers.length;
int min = Integer.MAX_VALUE;
for (int i = 0; i < N; i++) {
// 尝试第一张贴纸是谁
int[] sticker = stickers[i];
// 最关键的优化(重要的剪枝!这一步也是贪心!)
if (sticker[target[0] - 'a'] > 0) {
StringBuilder builder = new StringBuilder();
for (int j = 0; j < 26; j++) {
if (tcounts[j] > 0) {
int nums = tcounts[j] - sticker[j];
for (int k = 0; k < nums; k++) {
builder.append((char) (j + 'a'));
}
}
}
String rest = builder.toString();
min = Math.min(min, process2(stickers, rest));
}
}
return min + (min == Integer.MAX_VALUE ? 0 : 1);
}

public static int minStickers3(String[] stickers, String target) {
int N = stickers.length;
int[][] counts = new int[N][26];
for (int i = 0; i < N; i++) {
char[] str = stickers[i].toCharArray();
for (char cha : str) {
counts[i][cha - 'a']++;
}
}
HashMap<String, Integer> dp = new HashMap<>();
dp.put("", 0);
int ans = process3(counts, target, dp);
return ans == Integer.MAX_VALUE ? -1 : ans;
}

public static int process3(int[][] stickers, String t, HashMap<String, Integer> dp) {
if (dp.containsKey(t)) {
return dp.get(t);
}
char[] target = t.toCharArray();
int[] tcounts = new int[26];
for (char cha : target) {
tcounts[cha - 'a']++;
}
int N = stickers.length;
int min = Integer.MAX_VALUE;
for (int i = 0; i < N; i++) {
int[] sticker = stickers[i];
if (sticker[target[0] - 'a'] > 0) {
StringBuilder builder = new StringBuilder();
for (int j = 0; j < 26; j++) {
if (tcounts[j] > 0) {
int nums = tcounts[j] - sticker[j];
for (int k = 0; k < nums; k++) {
builder.append((char) (j + 'a'));
}
}
}
String rest = builder.toString();
min = Math.min(min, process3(stickers, rest, dp));
}
}
int ans = min + (min == Integer.MAX_VALUE ? 0 : 1);
dp.put(t, ans);
return ans;
}
两个字符串的最长公共子序列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static int longestCommonSubsequence(char[] str1, char[] str2) {
int N = str1.length;
int M = str2.length;
int[][] dp = new int[N][M];
dp[0][0] = str1[0] == str2[0] ? 1 : 0;
for (int i = 1; i < N; i++) {
dp[i][0] = str1[i] == str2[0] ? 1 : dp[i - 1][0];
}
for (int j = 1; j < M; j++) {
dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j - 1];
}
for (int i = 1; i < N; i++) {
for (int j = 1; j < M; j++) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
if (str1[i] == str2[j]) {
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - 1] + 1);
}
}
}
return dp[N - 1][M - 1];
}
洗咖啡杯机
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 贪心+优良尝试改成动态规划
public static int minTime2(int[] arr, int n, int a, int b) {
PriorityQueue<Machine> heap = new PriorityQueue<Machine>(new MachineComparator());
for (int i = 0; i < arr.length; i++) {
heap.add(new Machine(0, arr[i]));
}
int[] drinks = new int[n];
for (int i = 0; i < n; i++) {
Machine cur = heap.poll();
cur.timePoint += cur.workTime;
drinks[i] = cur.timePoint;
heap.add(cur);
}
return bestTimeDp(drinks, a, b);
}
public static int bestTimeDp(int[] drinks, int wash, int air) {
int N = drinks.length;
int maxFree = 0;
for (int i = 0; i < drinks.length; i++) {
maxFree = Math.max(maxFree, drinks[i]) + wash;
}
int[][] dp = new int[N + 1][maxFree + 1];
for (int index = N - 1; index >= 0; index--) {
for (int free = 0; free <= maxFree; free++) {
int selfClean1 = Math.max(drinks[index], free) + wash;
if (selfClean1 > maxFree) {
break; // 因为后面的也都不用填了
}
// index号杯子 决定洗
int restClean1 = dp[index + 1][selfClean1];
int p1 = Math.max(selfClean1, restClean1);
// index号杯子 决定挥发
int selfClean2 = drinks[index] + air;
int restClean2 = dp[index + 1][free];
int p2 = Math.max(selfClean2, restClean2);
dp[index][free] = Math.min(p1, p2);
}
}
return dp[0][0];
}

设计暴力递归过程的原则

重构-改善既有代码的设计

第一章,重构,第一个案例

1.如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构哪个程序,使特性的添加比较容易进行,然后再添加特性
2.重构之前,首先检查自己是否有一套可靠的测试机制。这些测试必须有自我检验能力
3.重构技术就是以微小的步伐修改程序,如果你犯下错误,很容易便可发现它。
4.任何一个傻瓜都能写出计算机可以理解的代码,唯有写出人类容易理解的代码, 才是优秀的程序员

第二章,重构原则

重构(名词):对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
重构(动词):使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构
1.事不过三,三则重构
2.不要过早发布接口,请修改你的代码所有权政策,使重构更顺畅

第三章,代码的坏味道

1.当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余

第四章,构筑测试体系

1.确保所有测试都完全自动化,让它们检查自己的测试结果
2.一套测试就是一个强大的bug侦测器,能够大大缩减查找bug所需要的空间
3.频繁地运行测试。每次编译请把测试也考虑进去–每天至少执行每个测试一次
4.每当你收到bug报告,请先写一个单元测试来暴露bug
5.编写未臻完善的测试并实际运行,好过对完美测试的无尽等待
6.考虑可能出错的边界条件,把测试火力集中在哪儿
7.当事情被认为应该会出错时,别忘记了检查是否抛出了预期的异常
8.不要因为测试无法捕捉所有bug就不写测试,因为测试的确可以捕捉到大多数bug

第八章,重新组织数据

检查重构目标是否为不可变对象,或是否可修改为不可变对象

如果该对象目前还不是不可变的,就使用Remove Setting Method(300),直到它成为不可变的为止

如果无法将该对象修改为不可变的,就放弃使用本项重构

建立equals()和hashCode()
编译,测试
考虑是否可以删除工厂函数,并将构造函数声明为pubilc
一个分层良好的系统,应该将处理用户界面和处理业务逻辑的代码分开。之所以这样做,原因有以下几点

1,你可能需要使用不同的用户界面来表现相同的业务逻辑,如果同时承担两种责任,用户界面会变得过于复杂,2,与GUI隔离之后,领域对象的维护和演化都会更容易,你甚至可以让不同的开发者负责不同部分的开发

第九章,简化条件表达式

1.从if,then,else三个段落中分别提炼出独立函数
2.将测试合并为一个条件表达式,并将这个条件表达式提炼成为一个独立函数
3.将重复代码搬到条件表达式之外
4.以break语句或return语句取代控制标记
5.使用卫语句表现所有特殊情况
6.将条件表达式的每个分支放进一个子类内的覆写函数中,然后将原始函数声明为抽象函数
7.将null值替换为null对象
8.以断言明确表现这种假设

第十章,简化函数调用

1.修改函数名称
2.为函数添加一个对象参数,让该对象带进函数所需信息
3.移除不必要的参数
4.将查询函数和修改函数分离
5.建立单一函数,以参数表示那些不同的值
6.以明确函数取代参数
7.保持对象完整
8.让参数接受者去除该项参数,并直接调用前一个函数
9.以一个对象取代此参数
10.去掉该字段的所有设值函数
11.将函数修改为private
12.将构造函数替换为工厂函数
13.将向下转型动作移到函数中
14.以异常取代错误码
15.以测试取代异常

第十一章,处理概括关系

1.将子类相同的字段移至父类
2.将子类相同功能的函数移至父类
3.如果各子类中拥有一些几乎一致的构造函数,请在父类中新建一个构造函数,并在子类构造函数中调用它
4.父类中的某个函数只与部分子类相关,将这个函数移到相关的子类去
5.父类中的某些字段只被部分子类用到,将这个字段移到需要它的子类去
6.如果类中的某些特性只被某些实例用到,新建一个子类,将那部分的特性移到子类中
7.如果两个类(或者多个类)有相似特性,为者两个类建立一个超类,将相同特性移至超类
8.将相同的子集提炼到一个独立接口中
9.父类和子类之间无太大区别,将它们合为一体
10.你有一些子类,其中相应的某些函数以相同顺序执行类似的操作,但各个操作的细节上有所不同,将这些操作分别放进独立函数中,并保持原函数上移超类
11.某个子类只使用超类接口中的一部分,或是根本不需要继承而来的数据,在子类中新建一个字段用以保存超类,调整子类函数,令它改而委托超类,然后去掉两者之间的继承关系
12.在两个类之间使用委托关系,并经常为整个接口编写许多极简单的委托函数,让委托类继承受托类

第十二章,大型重构

1.如果某个继承体系同时承担两项责任,建立两个继承体系,并通过委托关系让其中一个可以调用另一个
2.将过程化设计转化为对象设计
3.将领域逻辑分离出来,为它们建立独立的领域类
4.建立继承体系,以一个子类表示一种特殊情况

第十三章,重构,复用与实现

1.重构以求短期利益
2.降低重构带来的开销
3.安全地进行重构

并发编程艺术

1.如何减少上下文切换

减少上下文切换的方法有无锁并发编程,CAS算法,使用最少线程和使用协程

2.如何解决资源限制的问题

对于硬件资源限制,可以考虑使用集群并行执行程序,既然单机的资源有限制,那么就让程序在多机上运行,比如使用ODPS,Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。

对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接

3.volatile的两条实现原则

1.Lock前缀指令会引起处理器缓存回写到内存

2.一个处理器的缓存回写到内存会导致其他处理器的缓存无效

4.处理器如何实现原子操作

1.第一个机制时通过总线锁保证原子性

2.第二个机制是通过缓存锁定来保证原子性

但是有两种情况下处理器不会使用缓存锁定

第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定

第二种情况是:有些处理器不支持缓存锁定。

5.CAS实现原子操作的三大问题

1.ABA问题

2.循环时间长开销大

3.只能保证一个共享变量的原子操作

6.在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信时指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递
7.Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序原完全透明。
8.happens-before规则

1.程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作

2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

3.volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

4.传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

9.未同步程序的执行特性

差异

1.顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。

2.顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。

3.JMM不保证64位long型和double型的变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

10.由于Java的CAS同时具有volatile读和volatile写的内存语义因此Java线程之间的通信现在有了下面4种方式

1.A线程写volatile变量,随后B线程读这个volatile变量

2.A线程写volatile变量,随后B线程用CAS更新这个volatile变量

3.A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量

4.A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量

11.对于final,编译器和处理器要遵守两个重排序规则

1.在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

2.初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

12.线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会Java线程对于优先级的设定
13.锁降级指的是写锁降级成为读锁,如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程
14.合理地使用线程池能够带来3个好处

第一,降低资源消耗,通过重复利用已创建的线程降低线程创建和销毁造成的消耗

第二,提高响应速度,当任务到达时,任务可以不需要等到线程创建就能立即执行

第三,提高线程的可管理性,线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以统一分配,调优和监控。但是,要做到合理利用线程池,必须对其实现原理了如指掌。

15.在并发编程中使用生产者和消费者模式能够解决大多数并发问题。该模式通过平衡生产者和消费者的工作能力来提高程序整体处理数据的速度

设计模式

设计原则

1.针对接口编程,而不是针对实现编程

2.”针对接口编程“真正的意思是“针对超类型(supertype)编程”

3.多用组合少用继承

4.为了交互对象之间的松耦合设计而努力

5.类应该对扩展开放,对修改关闭

6.要依赖抽象,不要依赖具体类(依赖倒置原则)

7.最少知识原则:只和你的密友谈话

8.别调用我们,我们会调用你

9.一个类应该只有一个引起变化的原因

松耦合的威力

当两个对象之间松耦合,它们依然可以交互,但是不太清楚彼此的细节,观察者模式提供了一种对象设计,让主题和观察者之间松耦合

松耦合的设计之所以能让我们建立有弹性的OO系统,能够应对变化,是因为对象之间的互相依赖降低了最低

策略模式

定义了算法族,分别封装起来,让它们之间可以互相替换,此模式让算法的变化独立于使用算法的客户

要点

1.策略模式通常会用行为或算法配置Context类

观察者模式

定义了对象之间的一对多依赖,这样一来,当一个对象改变状态时,它的所有依赖者会收到通知并自动更新

要点

1.观察者模式定义了对象之间一对多的关系

2.主题(也就是可观察者)用一个共同的接口来更新观察者

3.观察者和可观察者之间用松耦合方式结合(loosecoupling),可观察者不知道观察者的细节,只知道观察者实现了观察者接口

4.使用此模式时,你可从被观察者处推(push)或拉(pull)数据(然而,推的方式被认为更“正确”)。

5.有多个观察者时,不可以依赖特定的通知次序

6.Java有多种观察者模式的实现,包括了通用的java.util.Observable实现上所带来的一些问题

7.如果有必要的话,可以实现自己的Observable,这并不难,不要害怕

8.Swing大量使用观察者模式,许多GUI框架也是如此

9.此模式也被应用在许多地方,例如:JavaBeans,RMI

装饰者模式

装饰者模式动态地将责任附加到对象上。若要扩展功能,装饰者提供了比继承更有弹性的替代方案

装饰者和被装饰对象有相同的超类型

你可以用一个或多个装饰者包装一个对象

既然装饰者和被装饰者有相同的超类型,所以在任何需要原始对象(被包装的)的场合,可以用装饰过的对象替代它

装饰者可以在所委托被装饰者的行为之前与/或之后,加上自己的行为,以达到特定的目的

对象可以在任何时候被装饰,所以可以在运行时动态地,不限量地用你喜欢的装饰者来装饰对象

要点

1.继承属于扩展形式之一,但不见得是达到弹性设计的最佳方式

2.在我们的设计中,应该允许行为可以被扩展,而无须修改现有的代码

3.组合和委托可用与在运行时动态地加上新的行为

4.除了继承装饰者模式也可以让我们扩展行为

5.装饰者模式意味着一群装饰者类,这些类用来包装具体组件

6.装饰者类放映处被装饰的组件类型(事实上,它们具有相同的类型,都经过接口或继承实现)

7.装饰者可以在被装饰者的行为前面与/或后面加上自己的行为,甚至将被装饰者的行为整个取代掉,而达到特定的目的

8.你可以用无数个装饰者包装一个组件

9.装饰者一般对组件的客户是透明的,除非客户程序依赖于组件的具体类型

10.装饰者会导致设计中出现许多小对象,如果过度使用,会让程序变得很复杂

简单工厂方法模式

定义了一个创建对象的接口,但由于子类决定要实例化的类是哪一个。工厂方法让类把实例化推迟到子类

依赖倒置原则

避免违反

1.变量不可以持有具体类的引用 (如果使用new,就会持有具体类的引用。你可以改用工厂避开这样的做法)

2.不要让类派生自具体类 (如果派生自具体类,你就会依赖具体类,请派生自一个抽象[接口或抽象类])

3.不要覆盖基类中已实现的方法 (如果覆盖基类已实现的方法,那么你的基类就不是一个真正适合被继承的抽象。基类中已实现的方法,应该由所有的子类共享)

要点

1.简单工厂,虽然不是真正的设计模式,但仍不失为一个简单的方法,可以将客户程序从具体类解耦

2.工厂方法使用继承,把对象的创建委托给子类,子类实现工厂方法来创建对象

3.工厂方法允许类将实例化延迟到子类进行

4.工厂是很有威力的技巧,帮助外面针对抽象编程,而不要针对具体类编程

抽象工厂模式

提供一个接口,用于创建相关或依赖对象的家族,而不需要明确指定具体类

要点

1.所有的工厂都是用来封装对象的创建

2.抽象工厂使用对象组合,对象的创建被实现在工厂接口所暴露出来的方法中

3.所有工厂模式都通过减少应用程序和具体类之间的依赖促进松耦合

4.抽象工厂创建相关的对象家族,而不需要依赖它们的具体类

5.依赖倒置原则,指导外面避免依赖具体类型,而要尽量依赖抽象

单例模式

确保一个类只有一个实例,并提供一个全局访问的

要点

1.单例模式确保程序中一个类最多只有一个实例

2.单例模式也提供访问这个实例的全局点

3.在Java中实现单例模式需要私有的构造器,一个静态方法和一个静态变量

4.确定在性能和资源上的限制,然后小心地选择适当的方案来实现单例,以解决多线程的问题(我们必须认定所有的程序都是多线程的)

5.小心,你如果使用多个类加载器,可能导致单例失效而产生多个实例

命令模式

将“请求”封装成对象,以便使用不同的请求,队列或者日志来参数化其他对象,命令模式也支持可撤销的操作

要点

1.命令模式将发出请求的对象和执行请求的对象解耦

2.在被解耦的两者之间是通过命令对象进行沟通的。命令对象封装了接收者和一个或一组动作

3.通用者通过调用命令对象的execute()发出请求,这会使得接收者的动作被调用

4.调用者可以接受命令当做参数,甚至在运行时动态地进行

5.命令可以支持撤销,做法是实现一个undo()方法来回到execute()被执行前的状态

6.宏命令是命令的一种简单的延伸,允许调用多个命令。宏方法也可以支持撤销

7.实际操作时,很常见使用“聪明”命令对象,也就是之间实现了请求,而不是将工作委托给接收者

8.命令也可以用来实现日志和事务系统

适配器模式

将一个类的接口,转换成客户期望的另一个接口。适配器让原来接口不兼容的类可以合作无间

外观模式

提供了一个统一的接口,用来访问子系统中的一群接口。外观定义了一个高层接口,让子系统更容易使用

要点

1.当需要使用一个现有的类而其接口并不符合你的需要时,就使用适配器

2.当需要简化并统一一个很大的接口或者一群复杂的接口时,使用外观

3.适配器改变接口以符合客户的期望

4.外观将客户从一个复杂的子系统中解耦

5.实现一个适配器可能需要一番功夫,也可能不费功夫,视目标接口的大小与复杂度而定

6.实现一个外观,需要将子系统组合进外观中,然后将工作委托给子系统执行

7.适配器模式有两种形式:对象适配器和类适配器。类适配器需要用到多重继承

8.你可以为一个子系统实现一个以上的外观

9.适配器将一个对象包装起来以改变其接口,装饰者将一个对象包装起来以增加新的行为和责任;而外观将一群对象“包装”起来以简化其接口

模板方法模式

在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中,模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤

要点

1.”模板方法”定义了算法的步骤,把这些步骤的实现延迟到子类

2.模板方法模式为我们提供了一种代码复用的重要技巧

3.模板方法的抽象类可以定义具体方法,抽象方法和钩子

4.抽象方法由子类实现

5.钩子是一种方法,它在抽象类中不做事,或者只做默认的事,子类可以选择要不要去覆盖它

6.为了防止子类改变模板方法中的算法,可以将模板方法声明为final

7.好莱坞原则告诉我们,将决策权放在高层模块中,以便决定如何以及何时调用底层模块

8.你将在真实世界代码中看到模板方法模式的许多变体,不要期待它们全都是一眼就可以被你认出的

9.策略模式和模板方法模式都封装算法,一个用组合,一个用继承

10.工厂方法是模板方法的一种特殊版本

迭代器模式

提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示

迭代器模式让我们能游走于聚合内的每一个元素,而不暴露其内部的表示

把游走的任务放在迭代器上,而不是聚合上,这样简化了聚合的接口和实现,也让责任各得其所

要点

1.迭代器允许访问聚合的元素,而不需要暴露它的内部结构

2.迭代器将遍历聚合的工作封装进一个对象中

3.当使用迭代器的时候,我们依赖聚合提供遍历

4.迭代器提供一个通用的接口,让我们遍历聚合的项,当我们编码使用聚合的项时,就可以使用多态

5.我们应该努力让一个类只分配一个责任

单一原则

类的每个责任都有改变的潜在区域。超过一个责任,意味者超过一个改变的区域

这个原则告诉我们,尽量让每个类保持单一责任

组合模式

允许你将对象组合成树型结构来表现“整体/部分”层次结构。组合能让客户以一致的方式处理个别对象以及对象组合

组合模式让我们能用树形创建对象的结构,树里面包含了组合以及个别的对象**

使用组合结构,我们能把相同的操作应用在组合和个别对象上,换句话说,在大多数情况下,我们可以忽略组合和个别对象之间的差别

要点

1.组合模式提供一个结构,可同时包容个别对象和组合对象

2.组合模式允许客户对个别对象以及组合对象一视同仁

3.组合结构内的任意对象称为组件,组件可以是组合,也可以是叶节点

4.在实现组合模式时,有许多设计上的折衷。你要根据需要平衡透明性和安全性

状态模式

允许对象在内部状态改变时改变它的行为,对象看起来好像修改了它的类

要点

1.状态模式允许一个对象基于内部状态而拥有不同得行为

2.和程序状态机(PSM)不同,状态模式用类代表状态

3.Context会将行为委托给当前状态对象

4.通过将每个状态封装进一个类,我们把以后需要做得任何改变局部化了

5.状态模式和策略模式有相同的类图,但是它们的意图不同

6..状态模式允许Context随着状态的改变而改变行为

7.状态转换可以由State类或Context类控制

8.使用状态模式通常会导致设计中类的数目大量增加

9.状态类可以被多个Context实例共享

代理模式

为另一个对象提供一个替身或占位符以控制对这个对象的访问

使用代理模式创建代表(representative)对象,让代表对象控制某对象的访问被代理的对象可以是远程的对象,创建开销打的对象或需要安全的控制对象

要点

1.代理模式为另一个对象提供代表,以便控制客户对对象的访问,管理访问的方式有许多种

2.远程代理管理客户和远程对象之间的交互

3.虚拟代理控制访问实例化开销大的对象

4.保护代理基于调用者控制对象方法的访问

5.代理模式有许多变体,例如:缓存代理,同步代理,防火墙代理和写入时复制代理

6.代理在结构上类似装饰者,但是目的不同

7.装饰者模式为对象加上行为,而代理则是控制访问

8.Java内置的代理支持,可以根据需要建立动态代理,并将所有调用分配到所选的处理器

9.就和其他的包装者(wrapper)一样,代理会造成你的设计中类的数目增加

复合模式

要点

1.MVC是复合模式,结合了观察者模式,策略模式和组合模式

2.模型使用观察者模式,以便观察者更新,同时保持两者之间解耦

3.控制器是视图的策略,视图可以使用不同的控制器实现,得到不同的行为

4.视图使用组合模式实现用户界面,用户界面通常组合了嵌套的组件,像面板,框架和按钮

5.这些模式携手合作,把MVC模型的三层解耦,这样可以保持设计干净又有弹性

6.适配器模式用来将新的模型适配成已有的视图和控制器

7.Model2是MVC在Web上的应用

8.在Model2中,控制器实现成Serblet,而JSP/HTML实现视图

反模式

告诉你如何采用一个不好的解决方案解决一个问题

桥接模式

使用桥接模式(Bridge Pattern)不只改变你的实现,也改变你的抽象

桥接模式通过将实现和抽象放在两个不同的类层次中而使它们可以独立改变

适合使用在需要跨越多个平台的图形和窗口系统上

当需要用不同的方式改变接口和实现时,你会发现桥接模式很好用

优点

1.将实现予以解耦,让它和界面之间不再永久绑定

2.抽象和实现可以独立扩展,不会影响到对方

3.对于“具体的抽象类”所做的改变,不会影响到客户

缺点

桥接模式的缺点是增加了复杂度

生成器模式

使用生成器模式(Builder Pattern)封装一个产品的构造过程,并允许按步骤构造

经常被用来创建组合结构

优点

1.将一个复杂对象的创建过程封装起来

2.允许对象通过多个步骤来创建,并且可以改变过程(这和只有一个步骤的工厂模式不同)

3.向客户隐藏产品内部的表现

4.产品的实现可以被替换,因为客户只看到一个对象的接口

缺点

与工厂模式相比,采用生成器模式创建对象的客户,需要具备更多的领域知识

责任链模式

当你想要让一个以上的对象有机会能够处理某个请求对象的时候,就使用责任链模式(Chain of Responsibility Pattern)

经常被使用在窗口系统中,处理鼠标和键盘之类的事件

优点

将请求的发送者和接收者解耦

可以简化你的对象,因为它不需要知道链的结构

通过改变链内的成员或调动它们的次序,允许你动态地新增或者删除责任

缺点

并不保证请求一定会被执行,如果没有任何对象处理它的话,它可能会落到链尾端之外(这可以是优点也可以是缺点

可能不容易观察运行时的特征,有碍于出错

蝇量模式

如果让某个类的一个实例能够用来提供许多“虚拟实例”,就使用蝇量模式(Flyweight Pattern)

当一个类有许多的实例,而这些实例能被同一方法控制的时候,我们就可以使用蝇量模式

优点

减少运行时对象实例的个数,节省内存

将许多“虚拟”对象的状态集中管理

缺点

一旦你实现了它,那么单个的逻辑实例将无法拥有独立而不同的行为

解释器模式

使用解释器模式(Interpreter Pattern)为语言创建解释器

当你需要实现一个简单的语言时,使用解释器

当你有一个简单的语法,而且简单比效率更重要时,使用解释器

可以处理脚本语言和编程语言

优点

将每一个语法规则表示成一个类,方便于实现语言

因为语法由许多类表示,所以你可以轻易地改变或扩展此语言

通过在类结构中加入新的方法,可以在解释的同时增加新的行为,例如打印格式的美化或者进行复杂的程序验证

缺点

当语法规则的数目太大时,这个模式可能会变得非常繁杂。在这种情况下,使用解析器或编译器的产生器可能更加合适

中介者模式

使用中介者模式(Mediator Pattern)来集中相关对象之间复杂的沟通和控制方式

中介者常常被用来协调相关的GUI组件

优点

通过将对象彼此解耦,可以增加对象的复用性

通过将控制逻辑集中,可以简化系统维护

可以让对象之间所传递的消息变得简单而且大幅减少

缺点

中介者模式的缺点是,如果设计不当,中介者对象本身会变得过于复杂

备忘录模式

当你需要让对象返回之前的状态时(例如,你的用户请求“撤销”),就使用备忘录模式(MementoPattern)

备忘录用于储存状态

目的

储存系统关键对象的重要状态

维护关键对象的封装

优点

将被储存的状态放在外面,不要和关键对象混在一起,这可以帮助维护内聚

保持关键对象的数据封装

提供了容易实现的恢复能力

缺点

储存和恢复状态的过程可能相当耗时

在Java系统时,其实可以考虑使用序列化(serialization)机制储存系统的状态

原型模式

当创建给定类的实例的过程很复杂时,就使用原型模式(Prototype Pattern)

在一个复杂的类层次中,当系统必须从其中的许多类型创建新对象时,可以考虑原型

优点

向客户隐藏制造新实例的复杂性

提供让客户能够产生未知类型对象的选项

在某些环境下,复制对象比创建新对象更有效

缺点

对象的复制有时相当复杂

访问者模式

当你想要成为一个对象的组合增加新的能力,且封装并不重要时,就使用访问者模式(Visitor Pattern)

当采用访问者模式的时候,就会打破组合类的封装

优点

允许你对组合结构加入新的操作,而无需改变结构本身

想要加入新的操作,相对容易

访问者所进行的操作,其代码是集中在一起的

缺点

因为游走的功能牵涉其中,所以对组合结构的改变就更加困难

定义设计模式

模式是在某情景(context)下,针对某问题的某种解决方案

情境就是应用某个模式的情况。这应该是会不断出现的情况

问题就是你想在某情境下达到的目标,但也可以是某情境下的约束

解决方案就是你所追求的:一个通用的设计,用来解决约束,达到目标

如果你发现自己处于某个情境下,面对这所欲达到的目标被一群约束影响着的问题,然而,你能够应用某个设计,克服这些约束并达到该目标,将你领向某个解决方案

要点

1.让设计模式自然而然地出现在你的设计中,而不是为了使用而使用

2.设计模式并非僵化的教条,你可以依据自己的需要采用或调整

3.总是使用满足需要的最简单解决方案,不管它用不用模式

4.学习设计模式的类目,可以帮你自己熟悉这些模式以及它们之间的关系

5.模式的分类(或类目)是将模式分成不用的族群,如果这么做对你有帮助,就采用吧

6.你必须相当专注才能够成为一个模式的作家,这需要时间也需要耐心,同时还必须乐意做大量的精化工作

7.请牢记:你所遇到大多数的模式都是现有模式的变体,而非新的模式

8.模式能够为你带来的最大好处之一是,让你的团队拥有共享词汇

9.任何社群都有自己的行话,模式社群也是如此。别让这些行话绊着,在读完这本书之后,你已经能够应用大部分的行话了

总结

模式 描述
装饰者 包装一个对象,以提供新的行为
状态 封装了基于状态的行为,并使用委托在行为之间切换
迭代器 在对象的集合之中游走,而不暴露集合的实现
外观 简化一群类的接口
策略 封装可以互换的行为,并使用委托来决定要使用哪一个
代理 包装对象,以控制对此对象的访问
工厂方法 由子类决定要创建的具体类是哪一个
适配器 封装对象,并提供不同的接口
观察者 让对象能够在状态改变时被通知
模板方法 客户用一致的方式处理对象集合和单个对象
组合 客户用一致的方式处理对象集合和单个对象
单件(单例) 确保有且只有一个对象被创建
抽象工厂 允许客户创建对象的家族,而无需指定他们的具体类
命令 封装请求成为对象

并发编程

第一部分

线程安全

1.通过从框架线程中调用应用程序的组件,框架把并发引入了应用程序。组件总是需要访问程序的状态。因此要求在所有代码路径访问状态时,必须时线程安全的
2.设计线程安全的类时,优秀的面向对象技术–封装,不可变性以及明确的不变约束–会给你提供诸多的帮助
3.当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不必作其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全
4.对于线程安全类的实例进行顺序或并发的一系列操作,都不会导致实例处于无效状态
5.线程安全的类封装了任何必要的同步,因此客户不需要自己提供
6.无状态对象永远是线程安全的
7.为了保护状态的一致性,要在单一的原子操作中更新相互关联的状态变量
8.对于每个可被多个线程访问的可变状态变量,如果所有访问它的线程在执行时都占有同一个锁,这种情况下,我们称这个变量是由这个锁保护的
9.每个共享的可变变量都需要由唯一一个确定的锁保护。而维护者应该清楚这个锁
10.对于每个涉及多个变量的不变约束,需要同一个锁保护其所有的变量
11.通常简单性与性能之间是相互牵制的。实现一个同步策略时,不要过早地维克性能而牺牲简单性(这是对安全性潜在的妥协)
12.有些耗时的计算或操作,比如网络或控制台I/O,难以快速完成,执行这些操作期间不要占有锁

共享对象

1.在没有同步的情况下,编译器,处理器,运行时安排操作的执行顺序可能完全出人意料。在没有进行适当同步的多线程程序中,尝试推断那些“必然”发生在内存中的动作时,你总是会判断错误
2.锁不仅仅是关于同步与互斥的,也是关于内存可见的,为了保证所有线程都能够看到共享的,可变变量的最新值,读取和写入线程必须使用公共的锁进行同步
3.只由当volatile变量能够简化实现和同步策略的验证时,才使用它们。当验证正确性必须推断可见性问题时,应该避免使用volatile变量。正确使用volatile变量的方式包括:用于确保它们所引用的对象状态的可见性,或者用于标识重要的生命周期事件(比如初始化或关闭)的发生
4.枷锁可以保证可见性与原子性,volatile变量只能保证可见性
5.使用volatile变量的基本要求
1.写入变量时并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
2.变量不需要与其他的状态变量共同参与不变约束
3.而且,访问变量时,没有其他的原因需要加锁
6.不要让this引用在构造期间逸出
7.不可变对象永远是线程安全的
8.只有满足如下状态,一个对象才是不可变的:
1.它的状态不能在创建后再被修改
2.所有域都是final类型
3.被正确创建(创建期间没有发生this引用的逸出)
9.正如“将所有的域声明为私有的,除非它们需要更高的可见性”一样,“将所有的域声明为final型,除非它们是可变的”,也是一条良好的实践
10.不可变对象可以在没有额外同步的情况下,安全地用于任意线程,甚至发布它们时亦不需要同步
11.为了安全地发布对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确创建的对象可以通过下列条件安全地发布:
1.通过静态初始化器初始化对象的引用
2.将它的引用存储到volatile域或AtomicReference
3.将它的引用存储到正确创建的对象的final域中
4.或者将它的引用存储到由锁正确保护的域中
12.任何线程都可以在没有额外的同步下安全地使用一个安全发布的高效不可变对象
13.发布对象的必要条件依赖于对象的可变性
1.不可变对象可以通过任意机制发布
2.高效不可变对象必须要安全发布
3.可变对象必须要安全发布,同时必须要线程安全或者时被锁保护

组合对象

1.设计线程安全类的过程应该包括下面3面基本要素:
1.确定对象状态是由那些变量构成的
2.确定限制状态变量的不变约束
3.制定一个管理并发访问对象状态的策略
2.不理解对象的不变约束和后验条件,你就不能保证线程安全性。要约束状态变量的有效值或者状态转换,就需要原子性与封装性0
3.将数据封装在对象内部,把对数据的访问限制在对象的方法上,更易确保线程在访问数据时总能获得正确的锁
4.限制性使构造线程安全的类变得更容易,因为类的状态被限制后,分析它的线程安全性时,就不必检查完整的程序
5.如果一个类由多个彼此独立的线程安全的状态变量组成,并且类的操作不包含任何无效状态转换时,可以将线程安全委托给这些状态变量
6.如果一个状态变量是线程安全的,没有任何不变约束限制它的值,并且没有任何状态转换限制它的操作,那么它可以被安全发布
7.为类的用户编写类线程安全性担保的文档,为类的维护者编写类的同步策略文档

构建块

1.正如封装一个对象的状态,能够使它更加容易地保持不变约束一样,封装它的同步则可以迫使它符合同步策略
2.用并发容器替换同步容器,这种作法以有很小风险带来了可扩展性显著的提高
3.相比于Hashtable和synchronizedMap,ConcurrentHashMap有众多的优势,而且几乎不存在什么劣势,因此在大多数情况下用ConcurrentHashMap取代同步Map实现只会带来更好的可伸缩性。只有当你的程序需要在独占访问中加锁时,ConurrentHashMap才无法胜任(ConcurrentHashMap是线程安全的)
4.有界队列是强大的资源管理工具,用来建立可靠的应用程序,它们遏制那些可以产生过多工作量,具有威胁的活动,从而让你的程序在面对超负荷工作时更加健壮

第一部分总结

1.所有并发问题都归结为如何协调访问并发状态,可变状态越少,保证线程安全就越发容易
2.尽量将域声明为final类型,除非它们的需要是可变的
3.不可变对象天生是线程安全的
4.不可变对象极大地减轻了并发编程的压力。它们简单而且安全,可以在没有锁或者防御性复制的情况下自由地共享
5.封装使管理复杂度变得更可行
6.用锁来守护每一个可变变量
7.对同一不变约束中的所有变量都使用相同的锁
8.在运行复合操作期间持有锁
9.在非同步的多线程情况下,访问可变变量的程序是存在隐患的
10.不要依赖于可以需要同步的小聪明
11.在设计过程中就考虑线程安全。或者在文档中明确地说明它不是线程安全的
12.文档化你的同步策略

第二部分

构建并发应用程序

任务执行

1.如果要在你的程序中实现一个生产者-消费者的设计,使用Executor通常是最简单的方式
2.无论何时当你看到这种形式的代码:
new Thread(runnable).start()
并且你可能最终希望获得一个更加灵活的执行策略时,请认真考虑使用Executor代替Thread
3.Executors中的静态工厂方法

image-20230921090327806

4.大量相互独立且同类的任务进行并发处理,会将程序的任务量分配到不同的任务中,这样才能真正获得性能的提升

取消和关闭

1.在API和语言规范中,并没有把中断与任何取消的语意绑定起来,但是,实际上,使用中断来处理取消之外的任何事情都是不明智的,并且很难支撑起更大的应用
2.调用interrupt并不意味着必然停止目标线程正在进行的工作,它仅仅传递了请求中断的消息
3.中断通常是实现取消最明智的选择
4.因为每一个线程都有其自己的中断策略,所以你不应该中断线程,除非你知道中断对这个线程意味着什么
5.只有实现了线程中断策略的代码才可以接收中断请求。通用目的的任务和库的代码绝不应该接收中断请求
6.对于线程持有的服务,只要服务的存在时间大于创建线程的方法存在的时间,那么就应该提供生命周期方法
7.在一个长时间运行的应用程序中,所有的线程都要给未捕获异常设置一个处理器,这个处理器至少要将异常信息记入日志中

精灵线程

8.线程被分为两种:普通线程和精灵线程(daemon thread)。JVM启动时创建所有的线程,除了主线程以外,其他的都是精灵线程(比如垃圾回收器和其他类似线程)。当一个新的线程创建时,新线程继承了创建它的线程的后台状态,所以默认情况下,任何主线程创建的线程都是普通线程。
9.普通线程和精灵线程之间的差别仅仅在于退出时会发生什么
10.应用程序中,精灵线程不能替代对服务的生命周期恰当,。良好的管理
11.避免使用finalizer

应用线程池

1.一些任务具有这样的特征:需要或者排斥某种特定的执行策略。对其他任务具有依赖性的任务,就会要求线程池足够大,来保证它所依赖任务不必排队或者不被拒绝,采用线程限制的任务需要顺序地执行。把这些需求都写入文档,这样将来的维护者就不会使用一个与原先相悖的执行策略,而破坏安全性或活跃度
2.无论何时,你提交了一个非独立的Executor任务,要明确出现线程饥饿死锁的可能性,并且,在代码或者配置文件以及其他可以配置Executor的地方,任何有关池的大小和配置约束都要写入文档
3.newCachedThreadPool工厂提供了比定长的线程池更好的队列等候性能,它是Executor的一个很好的默认选择。出于资源管理的目的,当你需要限制当前任务的数量,一个定长的线程池就是很好的选择。就像一个接受网络客户端请求的服务器应用程序,如果不进行限制,就会很容易因为过载而遭受攻击。
4.当每个迭代彼此独立,并且完成循环体中每个迭代的工作,意义都足够重大,足以弥补管理一个新任务的开销时,这个顺序循环是适合并行化的

总结

对于并发执行的任务,Executor框架是强大且灵活的。它提供了大量可调节的选项,比如创建和关闭线程的策略,处理队列任务的策略,处理过剩任务的策略,并且提供了几个钩子函数用于扩展它的行为,然而,和大多数强大的框架一样,草率地将一些设定组合在一起,并不能很好地工作;一些类型的任务需要特定的执行策略,而一些调节参数组合在一起后可能产生意外的结果

GUI应用程序

1.Swing的单线程规则:Swing的组件和模型只能在事件分派线程中被创建,修改和请求
2.如果一个数据模型必须被多个线程共享,而且实现一个线程安全模型的尝试却由于阻塞、一致性或者复杂度等原因而失败,这时可以考虑运用分拆模型设计
3.GUI框架几乎都是作为单线程化子系统实现的,所有与表现相关的代码都作为任务在一个事件线程中运行。因为只要唯一一个线程,耗时任务会损害响应性,所以它们应该在后台线程中运行。像SwingWorker以及构建BackgroundTask这些助手类,提供了对取消、进度指示、完成指示的支持/无论是GUI组件还是非GUI组件,都能借助它们简化耗时任务的开发

活跃度,性能和测试

避免活跃度危险

1.安全性和活跃度通常相互牵制。我们使用锁开保证线程安全,但是滥用锁可能引起锁顺序死锁(lock-ordering deadlock)。类似的,我们使用线程池和信号量来约束资源的使用,但是却不能知晓那些管辖范围内的活动可能形成的资源死锁(resource deadlock)。Java应用程序不能从死锁中恢复,所以确保你的设计能够避免死锁出现的先决条件是非常有价值的
2.如果所有线程以通用的固定秩序获得锁,程序就不会出现锁顺序死锁问题了
3.在持有锁的时候调用外部方法是在挑战活跃度问题。外部方法可能会获得其他锁(产生死锁的风险),或者遭遇严重超时的阻塞。当你持有锁的时候会延迟其他试图获得该锁的线程
4.当调用的方法不需要持有锁时,这被称为开放调用
5.在程序中尽量使用开发调用。依赖于开发调用程序,相比于那些在持有锁的时候还调用外部方法的程序,更容易进行死锁自由度(deadlock-freedom)的分析
6.抵制使用线程优先级的诱惑,因为这会增加平台依赖性,并且可能引起活跃度问题。大多数并发应用程序可以对所有线程使用的优先级
7.可伸缩性指的是:当增加计算资源的时候(比如增加额外CPU数量、内存、存储器、I/O带宽),吞吐量和生产量能够相应地得以改进
8.避免不成熟的优化,首先使程序正确,然后再加快–如果它运行得还不够快
9.测评。不要臆测
10.所有得并发程序都要一些串行源;如果你认为你没有,那么去仔细检查吧
11.不要过分担心非竞争得同步带来的开销。基础的机制已经足够快了,在这个基础上,JVM能够进行额外的优化,大大减少或消除了开销。关注那些真正发生了锁竞争的区域中性能的优化
12.串行化会损害可伸缩性,上下文切换回损害性能。竞争性的锁会同时导致这两种损失,所以减少锁的竞争能够改进性能和可伸缩性
13.并发程序中,对可伸缩性首要的威胁是独占的资源锁
14.三种方式来减少锁的竞争
1.减少持有锁的时间
2.减少请求锁的频率
3.或者用协调机制取代独占锁,从而允许更强的并发性
15.Amdahl定律告诉我们,程序的可伸缩性是由必须连续执行的代码比例决定的

总结

16.可伸缩性通常可以通过以下这些方式提升:减少用于获取锁的时间,减小锁的粒度,减少锁的占用时间,或者用非独占或非阻塞锁来取代独占锁

测试并发程序

1.为并发类创建有效的安全测试,其挑战在于:如何在程序出现问题并导致某些属性极度可能失败时,简单地识别出这些检查的属性来,同时不要人为的让查找错误的代码限制住程序的并发性。最好能做到在检查测试的属性时,不需要任何的同步。
2.测试应该在多处理器系统上运行,以提高潜在交替运行的多样性。但是,多个CPU未必会使测试更加高效,为了能够最大程度地检测到时序敏感的数据竞争的发生机会,应该让测试中的线程数多于CPU数,这样在任何给定的时间里,都要一些线程在运行,一些被交换出执行队列,这样可以增加线程间交替行为的随机性
3.编写有效的性能测试,就需要哄骗优化器不要把你的基准测试当作死代码而优化掉。这需要每一个计算的结果都要应用在你的程序中–以一种不需要的同步或真实计算的方式

高级主题

显示锁

1.性能是一个不断变化的目标,昨天的基准显示X比Y更快,这可能已经过时了
2.正如默认的ReentrantLock一样,内部锁没有提供确定的公平性保证,但是大多数锁实现统计上的公平性保证,在大多数条件下已经足够好了,Java语言规范并没有要求JVM公平地实现内部锁,JVM也的确没有这样做。ReentrantLock并没有减少锁的公平性–它只不过使一些存在的部分更显性化了
3.在内部锁不能够满足使用时,ReentrantLock才被作为更高级的工具。当你需要以下高级特性时,才应该使用;可定时的、可轮询的与可中断的锁获取操作,公平队列,或者非块结构的锁。否则,请使用synchronized
4.读-写锁(ReadWriteLock)允许多个读者并发访问被守护的对象,当访问多为读取数据结构的时候,它具有改进可伸缩性的潜力

构建自定义的同步工具

1.条件谓词是先验条件的第一站,它在一个操作与状态之间建立起依赖关系
2.将条件谓词和与之关联的条件队列,以及在条件队列中等待的操作,都写入文档
3.每次调用wait都会隐式地与特定的条件谓词相关联。当调用特定条件谓词的wait时,调用者必须已经持有了与条件队列相关的锁,这个锁必须同时还保护着组成条件谓词的状态变量
4.一个单独的内部条件队列可以与多个条件谓词共同使用
5.当使用条件等待(Object.wait或者Condition。await)
1.永远设置一个条件谓词–一些对象状态的测试,线程执行前必须满足它;
2.永远在调用wait前测试条件谓词,并且从wait中返回后再次测试
3.永远在循环中调用wait
4.确保构成条件谓词的状态变量被锁保护,而这个锁正是与条件队列相关联的;
5.当调用wait,notify或者notifyAll时,要持有与条件队列相关联的锁,并且
6.在检查条件谓词之后,开始执行被保护的逻辑之前,不要释放锁
7.无论何时,当你在等待一个条件,一定要确保有人会在条件谓词变为真时通知你
8.只有同时满足下述条件后,才能用单一的notify取代notifyAll(一般使用notifyAll):相同的等待者。只有一个条件谓词与条件队列相关,每个线程从wait返回执行行相同的逻辑;并且,一进一出。一个对条件变量的通知,至多只激活一个线程执行
9.尽管使用notifyAll而非notify可能有些低效,但是这样做更容易确保你的类的行为时正确的
10.一个依赖于状态的类,要么完全将它的等待和通知协议暴露(并文档化)给子类,要么完全阻止子类参与其中
11.危险警告:wait,notify和notifyAll在Condition对象中的对象对等体是await,signal和signalAll。但是,Condition继承Object,这意味着它也有wait和notify方法。一定要确保使用了正确的版本–await和signal!

原子变量与非阻塞同步机制

1.如果能够避免的话,不共享状态的开销会更小。能够通过更有效地竞争改进可伸缩性,但是真正的可伸缩完全是通过减少竞争实现的
2.非阻塞算法通过使用底层级并发原语,比如比较并交换,取代了锁。原子变量类向用户提供了这些底层级原语,也能够当作“更佳的volatile变量”使用,同时提供了整数类和对象引用的原子化更新操作
3.非阻塞算法在设计和实现中很困难,但是在典型条件下能够提供更好的可伸缩性,并能更好地预防活跃度失败。从JVM的一个版本到下一个版本间并发性能的提升很大程度上来源于非阻塞算法的使用,包括在JVM内部以及平台类库。

Java存储模型

1.happens-before的法则包括:
程序次序法制:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都出现在动作A之后
监视器锁法制:对一个监视器锁的解锁happens-before于每一个后续对同一监视器锁的加锁。
volatile变量法制:对volatile域的写入操作happens-before于每一个后续对同一域的读操作。
线程启动法制:在一个线程里,对Thread.start的调用会happens-before于每一个启动程序中的动作
线程启动法制:在一个线程里,对Thread.start的调用会happens-before于每一个启动线程中的动作
线程终结法制:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或者Thread.isAlive返回false。
中断法制:一个线程调用另一个线程的interrupt happens-before 于被中断的线程发现中断(通过抛出InterruptedException,获知调用isInterrupted和interrupted)
终结法制:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
传递性:如果A happens-before于B,且B happens-before 于C,则A happens-before于C
2.除了不可变对象以外,使用被另一个线程初始化的对象,是不安全的,除非对象的发布时happens-before于对象的消费线程使用它
3.初始化安全可以保证,对于正确创建的对象,无论它如何发布的,所有线程都将看到构造函数设置的final域的值,更进一步,一个正确创建的对象中,任何可以通过其final域触及到的变量(比如一个final数组中的元素,或者一个final域引用的HashMap里面的内容),也可以保证对其他线程都是可见的。
4.初始化安全性保证只有以通过final域触及的值,在构造函数完成时才是可见的。对于通过非final域触及的值,或者创建完成后可能改变的值,必须使用同步来确保可见性。

同步Annotation

3个类级Annotation来描述类的可预期的线程安全性保证:@Immutable ,@ThreadSafe 和 @NotThreadSafe

@Immutable自然是意味着类是不可变的,并包含了@ThreadSafe的意义。@NotThreadSafe是可选的–如果类没有被标明是线程安全的,就无法肯定它是不是线程安全的,但是如果你想明确地表示出它不是线程安全的,就标注为@NotThreadSafe

这些Annotation相对是非侵入的,这对用户和维护者都是有益的。用户可以立即看出一个类是否线程安全的,维护者也可以直接检查是否遵循了线程安全性保证。Annotation对于第三个利益既得者也是有用的:工具。静态的代码分析工具可以有能力对代码进行验证,看它是否遵循了由Annotation指定的契约,比如标明为@Immutable的类是否真是不可变的

JAVA建议

1.考虑用静态工厂方法代替构造函数

静态工厂方法的好处

1.与构造函数不同,静态工厂方法具有名字

2.与构造函数不同,它们每次被调用的时候,不要求非得创建一个新的对象

3.与构造函数不同,它可以返回一个原返回类型的子类型的对象

缺点

主要:类如果不含公有的或者受保护的构造函数,就不能被子类化

2.它们与其他的静态方法没有任何区别

2.使用私有构造函数强化singleton属性

3.通过私有构造函数强化不可实例化的能力

4.避免创建重复的对象

5.消除过期的对象引用

6.避免使用终结函数

7.在改写equals的时候请遵守通用约定

8.在改写equals时总是要改写hashCods

9.总是要改写toString

10.谨慎改写clone

11.考虑实现Comparable接口

12.使类和成员的可访问能力最小化

尽可能使每一个类或成员不被外界访问

13.支持非可变性

1.不要提供任何会修改对象的方法(也称为mutator)
2.保证没有可被子类改写的方法
3.使所有的域都是final的
4.使所有域都是成为私有的
5.保证对于任何可变组件的互斥访问

14.复合优先于继承

15.要么专门为继承而设计,并给出文档说明,要么禁止继承

一个类必须通过某种形式提供适合的钩子,以便能够进入到它的内部工作流程中,这样的形式可以是精心选择的受保护(protected)方法

构造函数一定不能调用可被改写的方法

无论是clone还是readObject,都不能调用一个可改写的方法,不管是直接的方式,还是间接的方式

为了继承设计一个类,要求对这个类有一些实质性的限制

对于那些并非为了安全地进行子类化而设计和编写文档类,禁止子类化

禁止子类化的两种方法

1.直接把这个类声明为final的

2.把所有的构造函数变成私有的,或者包级私有的,并且增加一些公有的静态工厂来替代构造函数的位置

16接口优于抽象类

接口和抽象类最大的区别是:抽象类允许包含某些方法的实现,但是接口是不允许的

已有的类可以很容易被更新,已实现新的接口
接口是定义mixin(混合类型)的理想选择
接口使得我们可以构造出非层次结构的类型框架
接口使得安全地增强一个类的功能成为可能

你可以把接口和抽象类的优点结合起来,对于你期望导出的每一个重要接口,都提供一个抽象的骨架实现(skeletal implementation)类

抽象类的演化比接口的演化要容易得多

17.接口只是被用于定义类型

常量接口模式是对接口的不良使用

18.优先考虑静态成员类

如果你声明的成员类不要求访问外围实例,那么请记住把static修饰符放到成员类的声明中

19.用类代替结构

20.用类层次来代替联合

21.用类来代替enum结构

22.用类和接口来代替函数指针

23.检查参数的有效性

24.需要时使用保护性拷贝

假设类的客户会尽一切手段来破坏这个类的约束条件,在这样的前提下,你必须保护性地设计程序
对于构造函数的每个可变参数进行保护性拷贝(defensive copy)是必要的
保护性拷贝动作是在检查参数的有效性之前进行的,并且有效性检查是针对拷贝之后的对象,而不是原始的对象
对于“参数类型可以被不可信方子类化”的情形,请不要使用clone方法进行参数的保护性拷贝

25.谨慎设计方法的原型

谨慎选择方法的名字
不要过于追求提供便利的方法
避免长长的参数列表
对于参数类型,优先使用接口而不是类
谨慎地使用函数对象

26.谨慎地使用重载

对于重载该方法(overloaded method)的选择是静态的,而对于被改写的方法(overridden method)的选择是动态的
避免方法重载机制的混淆用法
一个安全而保守的策略是,永远不要导出两个具有相同参数数目的重载方法

27.返回零长度的数组而不是null

没有理由从一个取数组值(array-valued)的方法中返回null,而不是返回一个零长度数组

28.为所有导出的API元素编写文档注释

为了正确地编写API文档,你必须在每一个被导出的类,接口,构造函数,方法和域声明之前增加一个文档注释
每一个方法的文档注释应该简洁地描述出它和客户之间的约定

29.将局部变量的作用域最小化

使一个局部变量的作用域最小化,最有力的技术是在第一次使用它的地方声明
几乎每一个局部变量的声明都应该包含一个初始化表达式

30.了解和使用库

通过使用标准库,你可以充分利用这些编写标准库的专家的知识,以及在你之前其他人的使用经验
在每一个主要的发行版本中,都会有许多新的特性被加入到库中,所以与这些库保持同步是值得的

31.如果要求精确的答案,请避免使用float和double

32.如果其他类型更合适,则尽量避免使用字符串

字符串不适合代替其他的值类型
字符串不适合代替枚举类型
字符串不适合代替聚集类型
字符串也不适合代替能力表

33.了解字符串连接的性能

为连接n个字符串而重复地使用字符串连接操作符,要求n的平方级的时间
为了获得可接受的性能,请使用StringBuffer替代String

34.通过接口引用对象

如果你养成了使用接口作为类型的习惯,那么你的程序将会更加灵活
如果没有合适的接口存在的话,那么,用类而不是接口来引用一个对象,是完全合适的

35.接口优先于映像机制

映像机制的代价
损失了编译时类型检查的好处
要求执行映像访问的代码非常笨拙和冗长
性能损失
通常,普通应用在运行时刻不应该以映像方式访问对象
如果只是在很有限的情况下使用映像机制,那么虽然也会付出少许代价,但你可以获得许多好处

36.谨慎地使用本地方法

37.谨慎地进行优化

努力避免那些限制性能的设计决定
考虑你的API设计决定的性能后果
为了获得好的性能而对API进行曲改,这是一个非常不好的想法
在每次试图做优化之前和之后,请对性能进行测量

39.只针对不正常的条件才使用异常

38.遵守普遍接受的命名惯例

异常只应该被同于不正常的条件,它们永远不应该被用于正常的控制流
一个设计良好的API不应该强迫它的客户为了正常的控制流而使用异常

40.对于可恢复的条件使用被检查的异常,对于程序错误使用运行时异常

如果期望调用者能够恢复,那么,对于这样的条件应该使用被检查的异常
用运行时异常来指明程序错误
你所实现的所有的未被检查的抛出结构都应该时RuntimeException的子类(直接的或者间接的)

41.避免不必要地使用被检查的异常

42.尽量使用标准的异常

43.抛出的异常要适合于相应的抽象

高层的实现应该捕获底层的异常,同时抛出一个可以按照高层抽象进行解释的异常
尽管异常转译比不加选择地传递低层异常的做法有所改进,但是它也不能被滥用

44.每个方法抛出的异常都要有文档

总是要单独地声明被检查的异常,并且利用Javadoc的@throws标记,标准地记录下每个异常被抛出的条件
使用Javadoc的@throws标签记录下一个方法可能会抛出的每个未被检查的异常,但是不要使用throws关键字将未被检查的异常包含在方法的声明中
如果一个类中的许多方法出于同样的原因而抛出同一个异常,那么在该类的文档注释中对这个异常做文档,而不是为每个方法单独做文档,这是可以接受的

45.在细节消息中包含失败-捕获消息

为了捕获失败,一个异常的字符串表示应该包含所有“对该异常有贡献”的参数和域的值

46.努力使失败保持原子性

一般而言,一个失败的方法调用应该使对象保持“它在被调用之前的状态”

47.不要忽略异常

空的catch块会使异常达不到应有的目的
至少catch块也应该包含一条说明,用来解释为什么忽略掉这个异常是适合适的

48对共享可变数据的同步访问

为了提高性能,在读或写原子数据的时候,你应该避免使用同步。这个建议是非常危险而错误的
为了在线程之间可靠地通信,以及为了互斥访问,同步是需要的
一般情况下,双重检查模式并不能正确地工作
简而言之,无论何时当多个线程共享可变数据的时候,每个读或者写数据的线程必须获得一把锁

49.避免过多的同步

为了避免死锁的危险,在一个被同步的方法或者代码块中,永远不要放弃对客户的控制

50.永远不要在循环的外面调用wait

总是使用wait循环模式来调用wait方法

51.不要依赖于线程调度器

任何依赖于线程调度器而达到正确性或性能要求的程序,很有可能是不可移植的
线程优先级是Java平台上最不可移植的特征了
对于大多数程序员来说,Thread.yield的惟一用途是在测试期间人为地增加一个程序的并发性

52.线程安全性的文档化

在一个方法的声明中出现synchronization修饰符,这是一个实现细节,并不是实现细节,并不是导出的API的一部分
一个类为了可破多个线程安全地使用,必须在文档中清楚地说明它所支持的线程安全性级别
安全级别

非可变的

线程安全的

有条件的线程安全

线程兼容的

线程对立的

53.避免使用线程组

线程组基本上已经过时了

54.谨慎地实现Serialization

因为实现Serialization而付出的最大代价是,一旦一个类被发布,则“改变这个类的实现”的灵活性将大大降低
实现Serialization的第二个代价是,它增加了错误(bug)和安全漏洞的可能性
实现Serialization的第三个代价是,随着一个类的新版本的发行,相关的测试负担增加了
实现Serialization接口不是一个很轻松就可以做出的决定
为了继承而设计的类应该很少实现Serialization,接口也应该很少会扩展它
对于为继承而设计的不可序列化的类,你应该考虑提供一个无参数的构造函数

55.考虑使用自定义的序列化形式

若没有认真考虑默认序列化形式是否合适,则不要接受这种形式
如果一个对象的物理表示等同于它的逻辑内容,则默认的序列化形式可能是合适的
即使你确定了默认序列化形式是合适的,通常你仍然要提供一个readObject方法以保证约束关系和安全性
当一个对象的物理表示与它的逻辑数据内容有实质性的区别时,使用默认序列化形式有4个缺点:

1.它使这个类的导出API永远地束缚在该类的内部表示上

2.它要消耗过多的空间

3.它要消耗过多的时间

4.它会引起栈溢出

transient修饰符表明这个实例域将从一个类的默认序列化形式中省略掉
如果所有的实例域都是transient的,那么,从技术角度而言,省去调用defaultWriteObject和defaultReadObject也是允许的,但是不推荐这么做
在决定将一个域做成非transient之前,请一定要确信它的值将是该对象逻辑状态的一部分
不管你选择了那种序列化形式,你都要为自己编写的每个可序列化的类声明一个显式的序列版本UID(serial version UID)

56.保护性地编写readObject方法

当一个对象被反序列化的时候,对于客户不应该拥有的对象引用,如果哪个域包含了这样的对象引用,则必须要做保护性拷贝,这是非常重要的

57.必要时提供一个readResolve方法

readResolve方法不仅仅对于singleton对象是必要的,而且对于所有其他的实例受控的(instance-controlled)类也是必需的
readResolve方法的第二个用法是,就像在第56条中建议的那样,作为保护性的readObject方法的一种保守的替代选择
尽管保护性readResolve模式并没有被广泛使用,但是它值得认真考虑
readResolve方法的可访问性(accessibility)是非常重要的
  • Copyrights © 2015-2025 Immanuel
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

微信