You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

108 KiB

面试问题整理

ZooKeeper

CAP定理

一个分布式系统不可能在满足分区容错性P的情况下同时满足一致性C和可用性A。在此ZooKeeper保证的是CPZooKeeper不能保证每次服务请求的可用性在极端环境下ZooKeeper可能会丢弃一些请求消费者程序需要重新请求才能获得结果。另外在进行leader选举时集群都是不可用所以说ZooKeeper不能保证服务可用性。

BASE理论

BASE理论是基本可用软状态最终一致性三个短语的缩写。BASE理论是对CAP中一致性和可用性CA权衡的结果其来源于对大规模互联网系统分布式实践的总结是基于CAP定理逐步演化而来的它大大降低了我们对系统的要求。

  1. 基本可用基本可用是指分布式系统在出现不可预知故障的时候允许损失部分可用性。但是这绝不等价于系统不可用。比如正常情况下一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果但由于出现故障查询结果的响应时间增加了1~2秒。
  2. 软状态:软状态指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
  3. 最终一致性:最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

ZooKeeper特点

  1. 顺序一致性:同一客户端发起的事务请求,最终将会严格地按照顺序被应用到 ZooKeeper 中去。
  2. 原子性:所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。
  3. 单一系统映像:无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。
  4. 可靠性:一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。

ZAB协议

ZAB协议包括两种基本的模式崩溃恢复和消息广播。当整个 Zookeeper 集群刚刚启动或者Leader服务器宕机、重启或者网络故障导致不存在过半的服务器与 Leader 服务器保持正常通信时,所有服务器进入崩溃恢复模式,首先选举产生新的 Leader 服务器,然后集群中 Follower 服务器开始与新的 Leader 服务器进行数据同步。当集群中超过半数机器与该 Leader 服务器完成数据同步之后退出恢复模式进入消息广播模式Leader 服务器开始接收客户端的事务请求生成事物提案(超过半数同意)来进行事务请求处理。

选举算法和流程FastLeaderElection(默认提供的选举算法)

目前有5台服务器每台服务器均没有数据它们的编号分别是1,2,3,4,5,按编号依次启动,它们的选择举过程如下:

  1. 服务器1启动给自己投票然后发投票信息由于其它机器还没有启动所以它收不到反馈信息服务器1的状态一直属于Looking。
  2. 服务器2启动给自己投票同时与之前启动的服务器1交换结果由于服务器2的编号大所以服务器2胜出但此时投票数没有大于半数所以两个服务器的状态依然是LOOKING。
  3. 服务器3启动给自己投票同时与之前启动的服务器1,2交换信息由于服务器3的编号最大所以服务器3胜出此时投票数正好大于半数所以服务器3成为leader服务器1,2成为follower。
  4. 服务器4启动给自己投票同时与之前启动的服务器1,2,3交换信息尽管服务器4的编号大但之前服务器3已经胜出所以服务器4只能成为follower。
  5. 服务器5启动后面的逻辑同服务器4成为follower。

zk中的监控原理

zk类似于linux中的目录节点树方式的数据存储即分层命名空间zk并不是专门存储数据的它的作用是主要是维护和监控存储数据的状态变化通过监控这些数据状态的变化从而可以达到基于数据的集群管理zk中的节点的数据上限时1M。

client端会对某个znode建立一个watcher事件当该znode发生变化时这些client会收到zk的通知然后client可以根据znode变化来做出业务上的改变等。

Redis

应用场景

  1. 缓存
  2. 共享Session
  3. 消息队列系统
  4. 分布式锁

单线程的Redis为什么快

  1. 纯内存操作
  2. 单线程操作,避免了频繁的上下文切换
  3. 合理高效的数据结构
  4. 采用了非阻塞I/O多路复用机制有一个文件描述符同时监听多个文件描述符是否有数据到来

Redis 的数据结构及使用场景

  1. String字符串:字符串类型是 Redis 最基础的数据结构,首先键都是字符串类型,而且 其他几种数据结构都是在字符串类型基础上构建的,我们常使用的 set key value 命令就是字符串。常用在缓存、计数、共享Session、限速等。
  2. Hash哈希:在Redis中哈希类型是指键值本身又是一个键值对结构哈希可以用来存放用户信息比如实现购物车。
  3. List列表双向链表:列表list类型是用来存储多个有序的字符串。可以做简单的消息队列的功能。
  4. Set集合集合set类型也是用来保存多个的字符串元素但和列表类型不一 样的是,集合中不允许有重复元素,并且集合中的元素是无序的,不能通过索引下标获取元素。利用 Set 的交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。
  5. Sorted Set有序集合跳表实现Sorted Set 多了一个权重参数 Score集合中的元素能够按 Score 进行排列。可以做排行榜应用,取 TOP N 操作。

Redis 的数据过期策略

Redis 中数据过期策略采用定期删除+惰性删除策略

  • 定期删除策略Redis 启用一个定时器定时监视所有的 key判断key是否过期过期的话就删除。这种策略可以保证过期的 key 最终都会被删除,但是也存在严重的缺点:每次都遍历内存中所有的数据,非常消耗 CPU 资源,并且当 key 已过期,但是定时器还处于未唤起状态,这段时间内 key 仍然可以用。
  • 惰性删除策略:在获取 key 时,先判断 key 是否过期,如果过期则删除。这种方式存在一个缺点:如果这个 key 一直未被使用,那么它一直在内存中,其实它已经过期了,会浪费大量的空间。
  • 这两种策略天然的互补,结合起来之后,定时删除策略就发生了一些改变,不在是每次扫描全部的 key 了,而是随机抽取一部分 key 进行检查,这样就降低了对 CPU 资源的损耗惰性删除策略互补了为检查到的key基本上满足了所有要求。但是有时候就是那么的巧既没有被定时器抽取到又没有被使用这些数据又如何从内存中消失没关系还有内存淘汰机制当内存不够用时内存淘汰机制就会上场。淘汰策略分为
    1. 当内存不足以容纳新写入数据时新写入操作会报错。Redis 默认策略)
    2. 当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 Key。LRU推荐使用
    3. 当内存不足以容纳新写入数据时,在键空间中,随机移除某个 Key。
    4. 当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 Key。这种情况一般是把 Redis 既当缓存,又做持久化存储的时候才用。
    5. 当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 Key。
    6. 当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 Key 优先移除。

Redis的set和setnx

Redis中setnx不支持设置过期时间做分布式锁时要想避免某一客户端中断导致死锁需设置lock过期时间在高并发时 setnx与 expire 不能实现原子操作如果要用得在程序代码上显示的加锁。使用SET代替SETNX 相当于SETNX+EXPIRE实现了原子性不必担心SETNX成功EXPIRE失败的问题。

Redis的LRU具体实现

传统的LRU是使用栈的形式每次都将最新使用的移入栈顶但是用栈的形式会导致执行select *的时候大量非热点数据占领头部数据所以需要改进。Redis每次按key获取一个值的时候都会更新value中的lru字段为当前秒级别的时间戳。Redis初始的实现算法很简单随机从dict中取出五个key,淘汰一个lru字段值最小的。在3.0的时候又改进了一版算法首先第一次随机选取的key都会放入一个pool中(pool的大小为16),pool中的key是按lru大小顺序排列的。接下来每次随机选取的keylru值必须小于pool中最小的lru才会继续放入直到将pool放满。放满之后每次如果有新的key需要放入需要将pool中lru最大的一个key取出。淘汰的时候直接从pool中选取一个lru最小的值然后将其淘汰。

如何解决 Redis 缓存雪崩问题

  1. 使用 Redis 高可用架构:使用 Redis 集群来保证 Redis 服务不会挂掉
  2. 缓存时间不一致,给缓存的失效时间,加上一个随机值,避免集体失效
  3. 限流降级策略:有一定的备案,比如个性推荐服务不可用了,换成热点数据推荐服务

如何解决 Redis 缓存穿透问题

  1. 在接口做校验
  2. 存null值缓存击穿加锁,或设置不过期)
  3. 布隆过滤器拦截: 将所有可能的查询key 先映射到布隆过滤器中查询时先判断key是否存在布隆过滤器中存在才继续向下执行如果不存在则直接返回。布隆过滤器将值进行多次哈希bit存储布隆过滤器说某个元素在可能会被误判。布隆过滤器说某个元素不在那么一定不在。

Redis的持久化机制

Redis为了保证效率数据缓存在了内存中但是会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件中以保证数据的持久化。Redis的持久化策略有两种 1. RDB快照形式是直接把内存中的数据保存到一个dump的文件中定时保存保存策略。 当Redis需要做持久化时Redis会fork一个子进程子进程将数据写到磁盘上一个临时RDB文件中。当子进程完成写临时文件后将原来的RDB替换掉。 1. AOF把所有的对Redis的服务器进行修改的命令都存到一个文件里命令的集合。 使用AOF做持久化每一个写命令都通过write函数追加到appendonly.aof中。aof的默认策略是每秒钟fsync一次在这种配置下就算发生故障停机也最多丢失一秒钟的数据。 缺点是对于相同的数据集来说AOF的文件体积通常要大于RDB文件的体积。根据所使用的fsync策略AOF的速度可能会慢于RDB。 Redis默认是快照RDB的持久化方式。对于主从同步来说主从刚刚连接的时候进行全量同步RDB全同步结束后进行增量同步(AOF)。

Redis和memcached的区别

  1. 存储方式上memcache会把数据全部存在内存之中断电后会挂掉数据不能超过内存大小。redis有部分数据存在硬盘上这样能保证数据的持久性。
  2. 数据支持类型上memcache对数据类型的支持简单只支持简单的key-value而redis支持五种数据类型。
  3. 用底层模型不同它们之间底层实现方式以及与客户端之间通信的应用协议不一样。redis直接自己构建了VM机制因为一般的系统调用系统函数的话会浪费一定的时间去移动和请求。
  4. value的大小redis可以达到1GB而memcache只有1MB。

Redis并发竞争key的解决方案

  1. 分布式锁+时间戳
  2. 利用消息队列

Redis与Mysql双写一致性方案

先更新数据库,再删缓存。数据库的读操作的速度远快于写操作的,所以脏数据很难出现。可以对异步延时删除策略,保证读请求完成以后,再进行删除操作。

Redis的管道pipeline

对于单线程阻塞式的RedisPipeline可以满足批量的操作把多个命令连续的发送给Redis Server然后一一解析响应结果。Pipelining可以提高批量处理性能提升的原因主要是TCP连接中减少了“交互往返”的时间。pipeline 底层是通过把所有的操作封装成流redis有定义自己的出入输出流。在 sync() 方法执行操作,每次请求放在队列里面,解析响应包。

Mysql

事务的基本要素

  1. 原子性:事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行
  2. 一致性:事务开始前和结束后,数据库的完整性约束没有被破坏。
  3. 隔离性:同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。
  4. 持久性:事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。

Mysql的存储引擎

  1. InnoDB存储引擎InnoDB存储引擎支持事务其设计目标主要面向在线事务处理OLTP的应用。其特点是行锁设计支持外键并支持非锁定锁即默认读取操作不会产生锁。从Mysql5.5.8版本开始InnoDB存储引擎是默认的存储引擎。
  2. MyISAM存储引擎MyISAM存储引擎不支持事务、表锁设计支持全文索引主要面向一些OLAP数据库应用。InnoDB的数据文件本身就是主索引文件而MyISAM的主索引和数据是分开的。
  3. NDB存储引擎NDB存储引擎是一个集群存储引擎其结构是share nothing的集群架构能提供更高的可用性。NDB的特点是数据全部放在内存中从MySQL 5.1版本开始可以将非索引数据放在磁盘上因此主键查找的速度极快并且通过添加NDB数据存储节点可以线性地提高数据库性能是高可用、高性能的集群系统。NDB存储引擎的连接操作是在MySQL数据库层完成的而不是在存储引擎层完成的。这意味着复杂的连接操作需要巨大的网络开销因此查询速度很慢。如果解决了这个问题NDB存储引擎的市场应该是非常巨大的。
  4. Memory存储引擎Memory存储引擎之前称HEAP存储引擎将表中的数据存放在内存中如果数据库重启或发生崩溃表中的数据都将消失。它非常适合用于存储临时数据的临时表以及数据仓库中的纬度表。Memory存储引擎默认使用哈希索引而不是我们熟悉的B+树索引。虽然Memory存储引擎速度非常快但在使用上还是有一定的限制。比如只支持表锁并发性能较差并且不支持TEXT和BLOB列类型。最重要的是存储变长字段时是按照定常字段的方式进行的因此会浪费内存。
  5. Archive存储引擎Archive存储引擎只支持INSERT和SELECT操作从MySQL 5.1开始支持索引。Archive存储引擎使用zlib算法将数据行row进行压缩后存储压缩比一般可达110。正如其名字所示Archive存储引擎非常适合存储归档数据如日志信息。Archive存储引擎使用行锁来实现高并发的插入操作但是其本身并不是事务安全的存储引擎其设计目标主要是提供高速的插入和压缩功能。
  6. Maria存储引擎Maria存储引擎是新开发的引擎设计目标主要是用来取代原有的MyISAM存储引擎从而成为MySQL的默认存储引擎。它可以看做是MyISAM的后续版本。Maria存储引擎的特点是支持缓存数据和索引文件应用了行锁设计提供了MVCC功能支持事务和非事务安全的选项以及更好的BLOB字符类型的处理性能。

事务的并发问题

  1. 脏读事务A读取了事务B更新的数据然后B回滚操作那么A读取到的数据是脏数据
  2. 不可重复读事务A多次读取同一数据事务B在事务A多次读取的过程中对数据作了更新并提交导致事务A多次读取同一数据时结果不一致。
  3. 幻读A事务读取了B事务已经提交的新增数据。注意和不可重复读的区别这里是新增不可重复读是更改或删除。select某记录是否存在不存在准备插入此记录但执行 insert 时发现此记录已存在,无法插入,此时就发生了幻读。

MySQL事务隔离级别

事务隔离级别 脏读 不可重复读 幻读
读未提交
不可重复读
可重复读
串行化

Mysql的逻辑结构

  • 最上层的服务类似其他CS结构比如连接处理授权处理。
  • 第二层是Mysql的服务层包括SQL的解析分析优化存储过程触发器视图等也在这一层实现。
  • 最后一层是存储引擎的实现类似于Java接口的实现Mysql的执行器在执行SQL的时候只会关注API的调用完全屏蔽了不同引擎实现间的差异。比如Select语句先会判断当前用户是否拥有权限其次到缓存内存查询是否有相应的结果集如果没有再执行解析sql检查SQL 语句语法是否正确再优化生成执行计划调用API执行。

SQL执行顺序

SQL的执行顺序from---where--group by---having---select---order by

MVCC,redolog,undolog,binlog

  • undoLog 也就是我们常说的回滚日志文件 主要用于事务中执行失败进行回滚以及MVCC中对于数据历史版本的查看。由引擎层的InnoDB引擎实现,是逻辑日志,记录数据修改被修改前的值,比如"把id='B' 修改为id = 'B2' 那么undo日志就会用来存放id ='B'的记录”。当一条数据需要更新前,会先把修改前的记录存储在undolog中,如果这个修改出现异常,,则会使用undo日志来实现回滚操作,保证事务的一致性。当事务提交之后undo log并不能立马被删除,而是会被放到待清理链表中,待判断没有事物用到该版本的信息时才可以清理相应undolog。它保存了事务发生之前的数据的一个版本用于回滚同时可以提供多版本并发控制下的读MVCC也即非锁定读。
  • redoLog 是重做日志文件是记录数据修改之后的值用于持久化到磁盘中。redo log包括两部分一是内存中的日志缓冲(redo log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(redo log file)该部分日志是持久的。由引擎层的InnoDB引擎实现,是物理日志,记录的是物理数据页修改的信息,比如“某个数据页上内容发生了哪些改动”。当一条数据需要更新时,InnoDB会先将数据更新然后记录redoLog 在内存中然后找个时间将redoLog的操作执行到磁盘上的文件上。不管是否提交成功我都记录你要是回滚了那我连回滚的修改也记录。它确保了事务的持久性。每个InnoDB存储引擎至少有1个重做日志文件组group每个文件组下至少有2个重做日志文件如默认的ib_logfile0和ib_logfile1。为了得到更高的可靠性用户可以设置多个的镜像日志组mirrored log groups将不同的文件组放在不同的磁盘上以此提高重做日志的高可用性。在日志组中每个重做日志文件的大小一致并以循环写入的方式运行。InnoDB存储引擎先写重做日志文件1当达到文件的最后时会切换至重做日志文件2再当重做日志文件2也被写满时会再切换到重做日志文件1中。
  • MVCC多版本并发控制是MySQL中基于乐观锁理论实现隔离级别的方式用于读已提交和可重复读取隔离级别的实现。在MySQL中会在表中每一条数据后面添加两个字段最近修改该行数据的事务ID指向该行undolog表中回滚段的指针。Read View判断行的可见性创建一个新事务时copy一份当前系统中的活跃事务列表。意思是当前不应该被本事务看到的其他事务id列表。已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,而可重复读隔离级别则在第一次读的时候生成一个ReadView之后的读都复用之前的ReadView。

binlog和redolog的区别

  1. redolog是在InnoDB存储引擎层产生而binlog是MySQL数据库的上层服务层产生的。
  2. 两种日志记录的内容形式不同。MySQL的binlog是逻辑日志其记录是对应的SQL语句对应的事务。而innodb存储引擎层面的重做日志是物理日志是关于每个页Page的更改的物理情况。
  3. 两种日志与记录写入磁盘的时间点不同binlog日志只在事务提交完成后进行一次写入。而innodb存储引擎的重做日志在事务进行中不断地被写入并日志不是随事务提交的顺序进行写入的。
  4. binlog不是循环使用在写满或者重启之后会生成新的binlog文件redolog是循环使用。
  5. binlog可以作为恢复数据使用主从复制搭建redolog作为异常宕机或者介质故障后的数据恢复使用。

Next-Key Lock

InnoDB 采用 Next-Key Lock 解决幻读问题。在insert into test(xid) values (1), (3), (5), (8), (11);由于xid上是有索引的该算法总是会去锁住索引记录。现在该索引可能被锁住的范围如下(-∞, 1], (1, 3], (3, 5], (5, 8], (8, 11], (11, +∞)。Session Aselect * from test where id = 8 for update)执行后会锁住的范围:(5, 8], (8, 11]。除了锁住8所在的范围还会锁住下一个范围所谓Next-Key。

InnoDB的关键特性

  1. 插入缓冲对于非聚集索引的插入或更新操作不是每一次直接插入到索引页中而是先判断插入的非聚集索引页是否在缓冲池中若在则直接插入若不在则先放入到一个Insert Buffer对象中。然后再以一定的频率和情况进行Insert Buffer和辅助索引页子节点的merge合并操作这时通常能将多个插入合并到一个操作中因为在一个索引页中这就大大提高了对于非聚集索引插入的性能。
  2. 两次写两次写带给InnoDB存储引擎的是数据页的可靠性有经验的DBA也许会想如果发生写失效可以通过重做日志进行恢复。这是一个办法。但是必须清楚地认识到如果这个页本身已经发生了损坏物理到page页的物理日志成功页内逻辑日志失败再对其进行重做是没有意义的。这就是说在应用apply重做日志前用户需要一个页的副本当写入失效发生时先通过页的副本来还原该页再进行重做。在对缓冲池的脏页进行刷新时并不直接写磁盘而是会通过memcpy函数将脏页先复制到内存中的doublewrite buffer之后通过doublewrite buffer再分两次每次1MB顺序地写入共享表空间的物理磁盘上这就是doublewrite。
  3. 自适应哈希索引InnoDB存储引擎会监控对表上各索引页的查询。如果观察到建立哈希索引可以带来速度提升则建立哈希索引称之为自适应哈希索引。
  4. 异步IO为了提高磁盘操作性能当前的数据库系统都采用异步IOAIO的方式来处理磁盘操作。AIO的另一个优势是可以进行IO Merge操作也就是将多个IO合并为1个IO这样可以提高IOPS的性能。
  5. 刷新邻接页当刷新一个脏页时InnoDB存储引擎会检测该页所在区extent的所有页如果是脏页那么一起进行刷新。这样做的好处显而易见通过AIO可以将多个IO写入操作合并为一个IO操作故该工作机制在传统机械磁盘下有着显著的优势。

Mysql如何保证一致性和持久性

MySQL为了保证ACID中的一致性和持久性使用了WAL(Write-Ahead Logging,先写日志再写磁盘)。Redo log就是一种WAL的应用。当数据库忽然掉电再重新启动时MySQL可以通过Redo log还原数据。也就是说每次事务提交时不用同步刷新磁盘数据文件只需要同步刷新Redo log就足够了。

InnoDB的行锁模式

  • 共享锁(S)用法lock in share mode又称读锁允许一个事务去读一行阻止其他事务获得相同数据集的排他锁。若事务T对数据对象A加上S锁则事务T可以读A但不能修改A其他事务只能再对A加S锁而不能加X锁直到T释放A上的S锁。这保证了其他事务可以读A但在T释放A上的S锁之前不能对A做任何修改。
  • 排他锁(X)用法for update又称写锁允许获取排他锁的事务更新数据阻止其他事务取得相同的数据集共享读锁和排他写锁。若事务T对数据对象A加上X锁事务T可以读A也可以修改A其他事务不能再对A加任何锁直到T释放A上的锁。在没有索引的情况下InnoDB只能使用表锁。

为什么选择B+树作为索引结构

  • Hash索引Hash索引底层是哈希表哈希表是一种以key-value存储数据的结构所以多个数据在存储关系上是完全没有任何顺序关系的所以对于区间查询是无法直接通过索引查询的就需要全表扫描。所以哈希索引只适用于等值查询的场景。而B+ 树是一种多路平衡查询树,所以他的节点是天然有序的(左子节点小于父节点、父节点小于右子节点),所以对于范围查询的时候不需要做全表扫描
  • 二叉查找树:解决了排序的基本问题,但是由于无法保证平衡,可能退化为链表。
  • 平衡二叉树:通过旋转解决了平衡的问题,但是旋转操作效率太低。
  • 红黑树:通过舍弃严格的平衡和引入红黑节点,解决了 AVL旋转效率过低的问题但是在磁盘等场景下树仍然太高IO次数太多。
  • B+树在B树的基础上将非叶节点改造为不存储数据纯索引节点进一步降低了树的高度此外将叶节点使用指针连接成链表范围查询更加高效。

B+树的叶子节点都可以存哪些东西

可能存储的是整行数据也有可能是主键的值。B+树的叶子节点存储了整行数据的是主键索引也被称之为聚簇索引。而索引B+ Tree的叶子节点存储了主键的值的是非主键索引也被称之为非聚簇索引

覆盖索引

指一个查询语句的执行只用从索引中就能够取得,不必从数据表中读取。也可以称之为实现了索引覆盖。

查询在什么时候不走(预期中的)索引

  1. 模糊查询 %like
  2. 索引列参与计算,使用了函数
  3. 非最左前缀顺序
  4. where对null判断
  5. where不等于
  6. or操作有至少一个字段没有索引
  7. 需要回表的查询结果集过大(超过配置的范围)

数据库优化指南

  1. 创建并使用正确的索引
  2. 只返回需要的字段
  3. 减少交互次数(批量提交)
  4. 设置合理的Fetch Size数据每次返回给客户端的条数

JVM

运行时数据区域

  1. 程序计数器:程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。是线程私有”的内存。
  2. Java虚拟机栈与程序计数器一样Java虚拟机栈Java Virtual Machine Stacks也是线程私有的它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型每个方法在执行的同时都会创建一个栈帧 ,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  3. 本地方法栈本地方法栈Native Method Stack与虚拟机栈所发挥的作用是非常相似的它们之间的区别不过是虚拟机栈为虚拟机执行Java方法也就是字节码服务而本地方法栈则为虚拟机使用到的Native方法服务。
  4. Java堆对于大多数应用来说Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例几乎所有的对象实例都在这里分配内存。
  5. 方法区方法区用于存储已被虚拟机加载的类信息、常量、静态变量如static修饰的变量加载类的时候就被加载到方法区中。运行时常量池是方法区的一部分class文件除了有类的字段、接口、方法等描述信息之外还有常量池用于存放编译期间生成的各种字面量和符号引用。在老版jdk方法区也被称为永久代。在1.8之后由于永久代内存经常不够用或发生内存泄露爆出异常java.lang.OutOfMemoryError所以在1.8之后废弃永久代引入元空间的概念。元空间是方法区的在HotSpot jvm 中的实现元空间的本质和永久代类似都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于元空间并不在虚拟机中而是使用本地内存。理论上取决于32位/64位系统可虚拟的内存大小。可见也不是无限制的需要配置参数。

分代回收

HotSpot JVM把年轻代分为了三部分1个Eden区和2个Survivor区分别叫from和to。一般情况下新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后如果仍然存活将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC年龄就会增加1岁当它的年龄增加到一定程度时就会被移动到年老代中。

因为年轻代中的对象基本都是朝生夕死的,所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

在GC开始的时候对象只会存在于Eden区和名为“From”的Survivor区Survivor区“To”是空的。紧接着进行GCEden区中所有存活的对象都会被复制到“To”而在“From”区中仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中没有达到阈值的对象会被复制到“To”区域。经过这次GC后Eden区和From区已经被清空。这个时候“From”和“To”会交换他们的角色也就是新的“To”就是上次GC前的“From”新的“From”就是上次GC前的“To”。不管怎样都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程直到“To”区被填满“To”区被填满之后会将所有对象移动到年老代中。

动态年龄计算

Hotspot在遍历所有对象时按照年龄从小到大对其所占用的大小进行累积当累积的某个年龄大小超过了survivor区的一半时取这个年龄和MaxTenuringThreshold中更小的一个值作为新的晋升年龄阈值。

JVM引入动态年龄计算主要基于如下两点考虑

  1. 如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件 aMaxTenuringThreshold设置的过大原本应该晋升的对象一直停留在Survivor区直到Survivor区溢出一旦溢出发生Eden+Svuvivor中对象将不再依据年龄全部提升到老年代这样对象老化的机制就失效了。 bMaxTenuringThreshold设置的过小“过早晋升”即对象不能在新生代充分被回收大量短期对象被晋升到老年代老年代空间迅速增长引起频繁的Major GC。分代回收失去了意义严重影响GC性能。
  2. 相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。

常见的垃圾回收机制

  1. 引用计数法:引用计数法是一种简单但速度很慢的垃圾回收技术。每个对象都含有一个引用计数器,当有引用连接至对象时,引用计数加1。当引用离开作用域或被置为null时,引用计数减1。虽然管理引用计数的开销不大,但这项开销在整个程序生命周期中将持续发生。垃圾回收器会在含有全部对象的列表上遍历,当发现某个对象引用计数为0时,就释放其占用的空间。
  2. 可达性分析算法这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点从这些节点开始向下搜索搜索所走过的路径称为引用链当一个对象到GC Roots没有任何引用链相连用图论的话来说就是从GC Roots到这个对象不可达则证明此对象是不可用的。

CMS的执行过程

  1. 初始标记(STW initial mark):这个过程从垃圾回收的"根对象"开始,只扫描到能够和"根对象"直接关联的对象并作标记。所以这个过程虽然暂停了整个JVM但是很快就完成了。
  2. 并发标记(Concurrent marking):这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记。并发标记阶段,应用程序的线程和并发标记的线程并发执行,所以用户不会感受到停顿。
  3. 并发预清理(Concurrent precleaning):并发预清理阶段仍然是并发的。在这个阶段,虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从新生代晋升到老年代, 或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段"重新标记"的工作因为下一个阶段会Stop The World。
  4. 重新标记(STW remark)这个阶段会暂停虚拟机收集器线程扫描在CMS堆中剩余的对象。扫描从"跟对象"开始向下追溯,并处理对象关联。
  5. 并发清理(Concurrent sweeping):清理垃圾对象,这个阶段收集器线程和应用程序线程并发执行。
  6. 并发重置(Concurrent reset)这个阶段重置CMS收集器的数据结构状态等待下一次垃圾回收。

G1的执行过程

  1. 标记阶段:首先是初始标记(Initial-Mark),这个阶段也是停顿的(stop-the-word)并且会稍带触发一次yong GC。
  2. 并发标记这个过程在整个堆中进行并且和应用程序并发运行。并发标记过程可能被yong GC中断。在并发标记阶段如果发现区域对象中的所有对象都是垃圾那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,每个区域的对象活性(区域中存活对象的比例)被计算。
  3. 再标记这个阶段是用来补充收集并发标记阶段产新的新垃圾。与之不同的是G1中采用了更快的算法:SATB。
  4. 清理阶段:选择活性低的区域(同时考虑停顿时间)等待下次yong GC一起收集对应GC log: [GC pause (mixed)],这个过程也会有停顿(STW)。
  5. 回收/完成新的yong GC清理被计算好的区域。但是有一些区域还是可能存在垃圾对象可能是这些区域中对象活性较高回收不划算也肯能是为了迎合用户设置的时间不得不舍弃一些区域的收集。

G1和CMS的比较

  1. CMS收集器是获取最短回收停顿时间为目标的收集器因为CMS工作时GC工作线程与用户线程可以并发执行以此来达到降低停顿时间的目的只有初始标记和重新标记会STW。但是CMS收集器对CPU资源非常敏感。在并发阶段虽然不会导致用户线程停顿但是会占用CPU资源而导致引用程序变慢总吞吐量下降。
  2. CMS仅作用于老年代是基于标记清除算法所以清理的过程中会有大量的空间碎片。
  3. CMS收集器无法处理浮动垃圾由于CMS并发清理阶段用户线程还在运行伴随程序的运行自热会有新的垃圾不断产生这一部分垃圾出现在标记过程之后CMS无法在本次收集中处理它们只好留待下一次GC时将其清理掉。
  4. G1是一款面向服务端应用的垃圾收集器适用于多核处理器、大内存容量的服务端系统。G1能充分利用CPU、多核环境下的硬件优势使用多个CPUCPU或者CPU核心来缩短STW的停顿时间它满足短时间停顿的同时达到一个高的吞吐量。
  5. 从JDK 9开始G1成为默认的垃圾回收器。当应用有以下任何一种特性时非常适合用G1Full GC持续时间太长或者太频繁对象的创建速率和存活率变动很大应用不希望停顿时间长(长于0.5s甚至1s)。
  6. G1将空间划分成很多块Region然后他们各自进行回收。堆比较大的时候可以采用采用复制算法碎片化问题不严重。整体上看属于标记整理算法,局部(region之间)属于复制算法。
  7. G1 需要记忆集来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率。所以 CMS 在小内存应用上的表现要优于 G1而大内存应用上 G1 更有优势大小内存的界限是6GB到8GB。Card TableCMS中的结构是一个连续的byte[]数组扫描Card Table的时间比扫描整个老年代的代价要小很多G1也参照了这个思路不过采用了一种新的数据结构 Remembered Set 简称Rset。RSet记录了其他Region中的对象引用本Region中对象的关系属于points-into结构谁引用了我的对象。而Card Table则是一种points-out我引用了谁的对象的结构每个Card 覆盖一定范围的Heap一般为512Bytes。G1的RSet是在Card Table的基础上实现的每个Region会记录下别的Region有指向自己的指针并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash TableKey是别的Region的起始地址Value是一个集合里面的元素是Card Table的Index。每个Region都有一个对应的Rset。

哪些对象可以作为GC Roots

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中JNI即一般说的Native方法引用的对象。

GC中Stop the worldSTW

在执行垃圾收集算法时Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起。此时系统只能允许GC线程进行运行其他线程则会全部暂停等待GC线程执行完毕后才能再次运行。这些工作都是由虚拟机在后台自动发起和自动完成的是在用户不可见的情况下把用户正常工作的线程全部停下来这对于很多的应用程序尤其是那些对于实时性要求很高的程序来说是难以接受的。

但不是说GC必须STW,你也可以选择降低运行速度但是可以并发执行的收集算法,这取决于你的业务。

垃圾回收算法

  1. 停止-复制:先暂停程序的运行,然后将所有存活的对象从当前堆复制到另一个堆,没有被复制的对象全部都是垃圾。当对象被复制到新堆时,它们是一个挨着一个的,所以新堆保持紧凑排列,然后就可以按前述方法简单,直接的分配了。缺点是一浪费空间,两个堆之间要来回倒腾,二是当程序进入稳定态时,可能只会产生极少的垃圾,甚至不产生垃圾,尽管如此,复制式回收器仍会将所有内存自一处复制到另一处。
  2. 标记-清除:同样是从堆栈和静态存储区出发,遍历所有的引用,进而找出所有存活的对象。每当它找到一个存活的对象,就会给对象一个标记,这个过程中不会回收任何对象。只有全部标记工作完成的时候,清理动作才会开始。在清理过程中,没有标记的对象会被释放,不会发生任何复制动作。所以剩下的堆空间是不连续的,垃圾回收器如果要希望得到连续空间的话,就得重新整理剩下的对象。
  3. 标记-整理:它的第一个阶段与标记/清除算法是一模一样的均是遍历GC Roots然后将存活的对象标记。移动所有存活的对象且按照内存地址次序依次排列然后将末端内存地址以后的内存全部回收。因此第二阶段才称为整理阶段。
  4. 分代收集算法把Java堆分为新生代和老年代然后根据各个年代的特点采用最合适的收集算法。新生代中对象的存活率比较低所以选用复制算法老年代中对象存活率高且没有额外空间对它进行分配担保所以使用“标记-清除”或“标记-整理”算法进行回收。

Minor GC和Full GC触发条件

  • Minor GC触发条件当Eden区满时触发Minor GC。
  • Full GC触发条件
    1. 调用System.gc时系统建议执行Full GC但是不必然执行
    2. 老年代空间不足
    3. 方法区空间不足
    4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
    5. 由Eden区、From Space区向To Space区复制时对象大小大于To Space可用内存则把该对象转存到老年代且老年代的可用内存小于该对象大小

JVM类加载过程

类从被加载到虚拟机内存中开始到卸载出内存为止它的整个生命周期包括加载、验证、准备、解析、初始化、使用和卸载7个阶段。

  1. 加载通过一个类的全限定名来获取定义此类的二进制字节流将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构在内存中生成一个代表这个类的Class对象作为方法去这个类的各种数据的访问入口
  2. 验证验证是连接阶段的第一步这一阶段的目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求并且不会危害虚拟自身的安全。
  3. 准备准备阶段是正式为类变量分配内存并设置类变量初始值的阶段这些变量所使用的内存都将在方法去中进行分配。这时候进行内存分配的仅包括类变量static而不包括实例变量实例变量将会在对象实例化时随着对象一起分配在Java堆中。
  4. 解析解析阶段是虚拟机将常量池内的符号Class文件内的符号引用替换为直接引用指针的过程。
  5. 初始化初始化阶段是类加载过程的最后一步开始执行类中定义的Java程序代码字节码

双亲委派模型

双亲委派的意思是如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。

双亲委派模型的"破坏"

一个典型的例子便是JNDI服务JNDI现在已经是Java的标准服务它的代码由启动类加载器去加载(在JDK 1.3时放进去的rt.jar)但JNDI的目的就是对资源进行集中管理和查找它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码那该怎么办?

为了解决这个问题Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的 setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承 一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

有了线程上下文类加载器就可以做一些“舞弊”的事情了JNDI服务使用这个线程上下 文类加载器去加载所需要的SPI代码也就是父类加载器请求子类加载器去完成类加载的动 作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经 违背了双亲委派模型的一般性原则但这也是无可奈何的事情。Java中所有涉及SPI的加载动 作基本上都采用这种方式例如JNDI、JDBC、JCE、JAXB和JBI等。

JVM锁优化和膨胀过程

  1. 自旋锁自旋锁其实就是在拿锁时发现已经有线程拿了锁自己如果去拿会阻塞自己这个时候会选择进行一次忙循环尝试。也就是不停循环看是否能等到上个线程自己释放锁。自适应自旋锁指的是例如第一次设置最多自旋10次结果在自旋的过程中成功获得了锁那么下一次就可以设置成最多自旋20次。
  2. 锁粗化:虚拟机通过适当扩大加锁的范围以避免频繁的拿锁释放锁的过程。
  3. 锁消除:通过逃逸分析发现其实根本就没有别的线程产生竞争的可能(别的线程没有临界量的引用),或者同步块内进行的是原子操作,而“自作多情”地给自己加上了锁。有可能虚拟机会直接去掉这个锁。
  4. 偏向锁:在大多数的情况下,锁不仅不存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获得锁的代价更低引入了偏向锁的概念。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。
  5. 轻量级锁当存在超过一个线程在竞争同一个同步代码块时会发生偏向锁的撤销。当前线程会尝试使用CAS来获取锁当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁。
  6. 重量级锁重量级锁依赖对象内部的monitor锁来实现而monitor又依赖操作系统的MutexLock互斥锁。当系统检查到是重量级锁之后会把等待想要获取锁的线程阻塞被阻塞的线程不会消耗CPU但是阻塞或者唤醒一个线程都需要通过操作系统来实现。

什么情况下需要开始类加载过程的第一个阶段加载

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时如果类没有进行过初始化则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是使用new关键字实例化对象的时候、读取或设置一个类的静态字段被final修饰、已在编译期把结果放入常量池的静态字段除外的时候以及调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候如果类没有进行过初始化则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时用户需要指定一个要执行的主类包含main方法的那个类虚拟机会先初始化这个主类。

i++操作的字节码指令

  1. 将int类型常量加载到操作数栈顶
  2. 将int类型数值从操作数栈顶取出并存储到到局部变量表的第1个Slot中
  3. 将int类型变量从局部变量表的第1个Slot中取出并放到操作数栈顶
  4. 将局部变量表的第1个Slot中的int类型变量加1
  5. 表示将int类型数值从操作数栈顶取出并存储到到局部变量表的第1个Slot中即i中

JVM性能监控

  1. JDK的命令行工具

    • jps(虚拟机进程状况工具)jps可以列出正在运行的虚拟机进程并显示虚拟机执行主类(Main Class,main()函数所在的类)名称 以及这些进程的本地虚拟机唯一ID(Local Virtual Machine Identifier,LVMID)。
    • jstat(虚拟机统计信息监视工具)jstat是用于监视虚拟机各种运行状态信息的命令行工 具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
    • jinfo(Java配置信息工具)jinfo的作用是实时地查看和调整虚拟机各项参数。
    • jmap(Java内存映像工具):命令用于生成堆转储快照(一般称为heapdump或dump文 件)。如果不使用jmap命令要想获取Java堆转储快照还有一些比较“暴力”的手段:譬如 在第2章中用过的-XX:+HeapDumpOnOutOfMemoryError参数可以让虚拟机在OOM异常出 现之后自动生成dump文件。jmap的作用并不仅仅是为了获取dump文件它还可以查询finalize执行队列、Java堆和永 久代的详细信息,如空间使用率、当前用的是哪种收集器等。
    • jhat(虚拟机堆转储快照分析工具)jhat命令与jmap搭配使用来分析jmap生成的堆 转储快照。jhat内置了一个微型的HTTP/HTML服务器生成dump文件的分析结果后可以在 浏览器中查看。
    • jstack(Java堆栈跟踪工具)jstack命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈 的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循 环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿 的时候通过jstack来查看各个线程的调用堆栈就可以知道没有响应的线程到底在后台做些 什么事情,或者等待着什么资源。
  2. JDK的可视化工具

    • JConsole
    • VisualVM

JVM常见参数

  1. -Xms20M表示设置JVM启动内存的最小值为20M必须以M为单位
  2. -Xmx20M表示设置JVM启动内存的最大值为20M必须以M为单位。将-Xmx和-Xms设置为一样可以避免JVM内存自动扩展。大的项目-Xmx和-Xms一般都要设置到10G、20G甚至还要高
  3. -verbose:gc表示输出虚拟机中GC的详细情况
  4. -Xss128k表示可以设置虚拟机栈的大小为128k
  5. -Xoss128k表示设置本地方法栈的大小为128k。不过HotSpot并不区分虚拟机栈和本地方法栈因此对于HotSpot来说这个参数是无效的
  6. -XX:PermSize=10M表示JVM初始分配的永久代方法区的容量必须以M为单位
  7. -XX:MaxPermSize=10M表示JVM允许分配的永久代方法区的最大容量必须以M为单位大部分情况下这个参数默认为64M
  8. -Xnoclassgc表示关闭JVM对类的垃圾回收
  9. -XX:+TraceClassLoading表示查看类的加载信息
  10. -XX:+TraceClassUnLoading表示查看类的卸载信息
  11. -XX:NewRatio=4表示设置年轻代包括Eden和两个Survivor区/老年代 的大小比值为14这意味着年轻代占整个堆的1/5
  12. -XX:SurvivorRatio=8表示设置2个Survivor区1个Eden区的大小比值为2:8这意味着Survivor区占整个年轻代的1/5这个参数默认为8
  13. -Xmn20M表示设置年轻代的大小为20M
  14. -XX:+HeapDumpOnOutOfMemoryError表示可以让虚拟机在出现内存溢出异常时Dump出当前的堆内存转储快照
  15. -XX:+UseG1GC表示让JVM使用G1垃圾收集器
  16. -XX:+PrintGCDetails表示在控制台上打印出GC具体细节
  17. -XX:+PrintGC表示在控制台上打印出GC信息
  18. -XX:PretenureSizeThreshold=3145728表示对象大于31457283M时直接进入老年代分配这里只能以字节作为单位
  19. -XX:MaxTenuringThreshold=1表示对象年龄大于1自动进入老年代,如果设置为0的话则年轻代对象不经过Survivor区直接进入年老代。对于年老代比较多的应用可以提高效率。如果将此值设置为一个较大值则年轻代对象会在Survivor区进行多次复制这样可以增加对象在年轻代的存活时间增加在年轻代被回收的概率。
  20. -XX:CompileThreshold=1000表示一个方法被调用1000次之后会被认为是热点代码并触发即时编译
  21. -XX:+PrintHeapAtGC表示可以看到每次GC前后堆内存布局
  22. -XX:+PrintTLAB表示可以看到TLAB的使用情况
  23. -XX:+UseSpining开启自旋锁
  24. -XX:PreBlockSpin更改自旋锁的自旋次数使用这个参数必须先开启自旋锁
  25. -XX:+UseSerialGC表示使用jvm的串行垃圾回收机制该机制适用于单核cpu的环境下
  26. -XX:+UseParallelGC表示使用jvm的并行垃圾回收机制该机制适合用于多cpu机制同时对响应时间无强硬要求的环境下使用-XX:ParallelGCThreads=设置并行垃圾回收的线程数,此值可以设置与机器处理器数量相等。
  27. -XX:+UseParallelOldGC表示年老代使用并行的垃圾回收机制
  28. -XX:+UseConcMarkSweepGC表示使用并发模式的垃圾回收机制该模式适用于对响应时间要求高具有多cpu的环境下
  29. -XX:MaxGCPauseMillis=100设置每次年轻代垃圾回收的最长时间如果无法满足此时间JVM会自动调整年轻代大小以满足此值。
  30. -XX:+UseAdaptiveSizePolicy设置此选项后并行收集器会自动选择年轻代区大小和相应的Survivor区比例以达到目标系统规定的最低响应时间或者收集频率等此值建议使用并行收集器时一直打开

JVM调优目标-何时需要做jvm调优

  1. heap 内存(老年代)持续上涨达到设置的最大内存值;
  2. Full GC 次数频繁;
  3. GC 停顿时间过长超过1秒
  4. 应用出现OutOfMemory 等内存异常;
  5. 应用中有使用本地缓存且占用大量内存空间;
  6. 系统吞吐量与响应性能不高或下降。

JVM调优实战

  1. Major GC和Minor GC频繁

    首先优化Minor GC频繁问题。通常情况下由于新生代空间较小Eden区很快被填满就会导致频繁Minor GC因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下新生代中的Eden区增加一倍Minor GC的次数就会减少一半。

    扩容Eden区虽然可以减少Minor GC的次数但会增加单次Minor GC时间么扩容后Minor GC时增加了T1扫描时间但省去T2复制对象的时间更重要的是对于虚拟机来说复制对象的成本要远高于扫描成本所以单次Minor GC时间更多取决于GC后存活对象的数量而非Eden区的大小。因此如果堆中短期对象很多那么扩容新生代单次Minor GC时间不会显著增加。

  2. 请求高峰期发生GC导致服务可用性下降

    由于跨代引用的存在CMS在Remark阶段必须扫描整个堆同时为了避免扫描时新生代有很多对象增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制如果超时等不到Minor GCRemark时新生代仍然有很多对象我们的调优策略是通过参数强制Remark前进行一次Minor GC从而降低Remark阶段的时间。 另外类似的JVM是如何避免Minor GC时扫描全堆的 经过统计信息显示老年代持有新生代对象引用的情况不足1%根据这一特性JVM引入了卡表card table来实现这一目的。卡表的具体策略是将老年代的空间分成大小为512B的若干张卡card。卡表本身是单字节数组数组中的每个元素对应着一张卡当发生老年代引用新生代时虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示卡表3被标记为脏卡表还有另外的作用标识并发标记阶段哪些块被修改过之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式避免了全堆扫描。

  3. STW过长的GC

    对于性能要求很高的服务建议将MaxPermSize和MinPermSize设置成一致JDK8开始Perm区完全消失转而使用元空间。而元空间是直接存在内存中不在JVM中Xms和Xmx也设置为相同这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有即使扩容前有一部分内存不会被用户代码用到这部分内存在虚拟机中被标识为虚拟内存也不会交给其他进程使用。

  4. 外部命令导致系统缓慢

    一个数字校园应用系统发现请求响应时间比较慢通过操作系统的mpstat工具发现CPU使用率很高并且系统占用绝大多数的CPU资 源的程序并不是应用系统本身。每个用户请求的处理都需要执行一个外部shell脚本来获得系统的一些信息执行这个shell脚本是通过Java的 Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到目的但是它在Java 虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程 的开销也非常可观。Java虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程再用这个新的进程去执行外部命令最后再退出这个进程。如果频繁执行这个操作系统的消耗会很大不仅是CPU内存负担也很重。用户根据建议去掉这个Shell脚本执行的语句改为使用Java的API去获取这些信息后系统很快恢复了正常。

  5. 由Windows虚拟内存导致的长时间停顿

    一个带心跳检测功能的GUI桌面程序每15秒会发送一次心跳检测信号如果对方30秒以内都没有信号返回那就认为和对方程序的连接已经断开。程序上线后发现心跳 检测有误报的概率,查询日志发现误报的原因是程序会偶尔出现间隔约一分钟左右的时间完 全无日志输出,处于停顿状态。

    因为是桌面程序,所需的内存并不大(-Xmx256m)所以开始并没有想到是GC导致的 程序停顿,但是加入参数-XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDateStamps- Xloggc:gclog.log后从GC日志文件中确认了停顿确实是由GC导致的大部分GC时间都控 制在100毫秒以内但偶尔就会出现一次接近1分钟的GC。

    从GC日志中找到长时间停顿的具体日志信息(添加了-XX:+PrintReferenceGC参数) 找到的日志片段如下所示。从日志中可以看出真正执行GC动作的时间不是很长但从准 备开始GC到真正开始GC之间所消耗的时间却占了绝大部分。

    除GC日志之外还观察到这个GUI程序内存变化的一个特点当它最小化的时候资源 管理中显示的占用内存大幅度减小但是虚拟内存则没有变化因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了这样发生GC时就有可能因为恢复页面文件的操作而导致不正常的GC停顿。在Java的GUI程序中要避免这种现象可以 加入参数“-Dsun.awt.keepWorkingSetOnMinimize=true”来解决。

Java基础

HashMap和ConcurrentHashMap

由于HashMap是线程不同步的虽然处理数据的效率高但是在多线程的情况下存在着安全问题因此设计了CurrentHashMap来解决多线程安全问题。

HashMap在put的时候插入的元素超过了容量由负载因子决定的范围就会触发扩容操作就是rehash这个会重新将原数组的内容重新hash到新的扩容数组中在多线程的环境下存在同时其他的元素也在进行put操作如果hash值相同可能出现同时在同一数组下用链表表示造成闭环导致在get时会出现死循环所以HashMap是线程不安全的。

HashMap的环若当前线程此时获得ertry节点但是被线程中断无法继续执行此时线程二进入transfer函数并把函数顺利执行此时新表中的某个位置有了节点之后线程一获得执行权继续执行因为并发transfer所以两者都是扩容的同一个链表当线程一执行到e.next = new table[i] 的时候由于线程二之前数据迁移的原因导致此时new table[i] 上就有ertry存在所以线程一执行的时候会将next节点设置为自己导致自己互相使用next引用对方因此产生链表导致死循环。

在JDK1.7版本中ConcurrentHashMap维护了一个Segment数组Segment这个类继承了重入锁ReentrantLock并且该类里面维护了一个 HashEntry<K,V>[] table数组在写操作putremove扩容的时候会对Segment加锁所以仅仅影响这个Segment不同的Segment还是可以并发的所以解决了线程的安全问题同时又采用了分段锁也提升了并发的效率。在JDK1.8版本中ConcurrentHashMap摒弃了Segment的概念而是直接用Node数组+链表+红黑树的数据结构来实现并发控制使用Synchronized和CAS来操作整个看起来就像是优化过且线程安全的HashMap。

HashMap如果我想要让自己的Object作为K应该怎么办

  1. 重写hashCode()是因为需要计算存储数据的存储位置需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能这样虽然能更快但可能会导致更多的Hash碰撞
  2. 重写equals()方法需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值xx.equals(null)必须返回false的这几个特性目的是为了保证key在哈希表中的唯一性Java建议重写equal方法的时候需重写hashcode的方法

volatile

volatile在多处理器开发中保证了共享变量的“ 可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。(共享内存,私有内存)

Atomic类的CAS操作

CAS是英文单词CompareAndSwap的缩写中文意思是比较并替换。CAS需要有3个操作数内存地址V旧的预期值A即将要更新的目标值B。CAS指令执行时当且仅当内存地址V的值与预期值A相等时将内存地址V的值修改为B否则就什么都不做。整个比较并替换的操作是一个原子操作。如 Intel 处理器,比较并交换通过指令的 cmpxchg 系列实现。

CAS操作ABA问题

如果在这段期间它的值曾经被改成了B后来又被改回为A那CAS操作就会误认为它从来没有被改变过。Java并发包为了解决这个问题提供了一个带有标记的原子引用类“AtomicStampedReference”它可以通过控制变量值的版本来保证CAS的正确性。

Synchronized和Lock的区别

  1. 首先synchronized是java内置关键字在jvm层面Lock是个java类。
  2. synchronized无法判断是否获取锁的状态Lock可以判断是否获取到锁并且可以主动尝试去获取锁。
  3. synchronized会自动释放锁(a 线程执行完同步代码会释放锁 b 线程执行过程中发生异常会释放锁)Lock需在finally中手工释放锁unlock()方法释放锁),否则容易造成线程死锁。
  4. 用synchronized关键字的两个线程1和线程2如果当前线程1获得锁线程2线程等待。如果线程1阻塞线程2则会一直等待下去而Lock锁就不一定会等待下去如果尝试获取不到锁线程可以不用一直等待就结束了。
  5. synchronized的锁可重入、不可中断、非公平而Lock锁可重入、可判断、可公平两者皆可
  6. Lock锁适合大量同步的代码的同步问题synchronized锁适合代码少量的同步问题。

AQS理论的数据结构

AQS内部有3个对象一个是state用于计数器类似gc的回收计数器一个是线程标记当前线程是谁加锁的一个是阻塞队列。

AQS是自旋锁在等待唤醒的时候经常会使用自旋的方式不停地尝试获取锁直到被其他线程获取成功。

AQS有两个队列同步对列和条件队列。同步队列依赖一个双向链表来完成同步状态的管理当前线程获取同步状态失败后同步器会将线程构建成一个节点并将其加入同步队列中。通过signal或signalAll将条件队列中的节点转移到同步队列。

如何指定多个线程的执行顺序

  1. 设定一个 orderNum每个线程执行结束之后更新 orderNum指明下一个要执行的线程。并且唤醒所有的等待线程。
  2. 在每一个线程的开始,要 while 判断 orderNum 是否等于自己的要求值,不是,则 wait是则执行本线程。

为什么要使用线程池

  1. 减少创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
  2. 可以根据系统的承受能力,调整线程池中工作线程的数目,放置因为消耗过多的内存,而把服务器累趴下。

核心线程池ThreadPoolExecutor内部参数

  1. corePoolSize指定了线程池中的线程数量
  2. maximumPoolSize指定了线程池中的最大线程数量
  3. keepAliveTime线程池维护线程所允许的空闲时间
  4. unit: keepAliveTime 的单位。
  5. workQueue任务队列被提交但尚未被执行的任务。
  6. threadFactory线程工厂用于创建线程一般用默认的即可。
  7. handler拒绝策略。当任务太多来不及处理如何拒绝任务。

线程池都有哪几种工作队列

  1. ArrayBlockingQueue底层是数组有界队列如果我们要使用生产者-消费者模式,这是非常好的选择。
  2. LinkedBlockingQueue底层是链表可以当做无界和有界队列来使用所以大家不要以为它就是无界队列。
  3. SynchronousQueue本身不带有空间来存储任何元素使用上可以选择公平模式和非公平模式。
  4. PriorityBlockingQueue无界队列基于数组数据结构为二叉堆数组第一个也是树的根节点总是最小值。

线程池的拒绝策略

  1. ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
  2. ThreadPoolExecutor.DiscardPolicy丢弃任务但是不抛出异常。
  3. ThreadPoolExecutor.DiscardOldestPolicy丢弃队列最前面的任务然后重新提交被拒绝的任务
  4. ThreadPoolExecutor.CallerRunsPolicy由调用线程提交任务的线程处理该任务

线程池的线程数量怎么确定

  1. 一般来说如果是CPU密集型应用则线程池大小设置为N+1。
  2. 一般来说如果是IO密集型应用则线程池大小设置为2N+1。
  3. 在IO优化中线程等待时间所占比例越高需要越多线程线程CPU时间所占比例越高需要越少线程。这样的估算公式可能更适合最佳线程数目 = ((线程等待时间+线程CPU时间/线程CPU时间 * CPU数目

如何实现一个带优先级的线程池

利用priority参数继承 ThreadPoolExecutor 使用 PriorityBlockingQueue 优先级队列。

ThreadLocal的原理和实现

ThreadLoal 变量,线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时它所使用的所有 ThreadLocal 相对的实例副本都可被回收。

一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map ,这个 Map 不是直接使用的 HashMap ,而是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。而我们使用的 get()、set() 方法其实都是调用了这个ThreadLocalMap类对应的 get()、set() 方法。

ThreadLocal中的内存泄漏

在ThreadLocal中内存泄漏是指ThreadLocalMap中的Entry中的key为null而value不为null。因为key为null导致value一直访问不到而根据可达性分析导致在垃圾回收的时候进行可达性分析的时候,value可达从而不会被回收掉但是该value永远不能被访问到这样就存在了内存泄漏。如果 key 是强引用,那么发生 GC 时 ThreadLocalMap 还持有 ThreadLocal 的强引用,会导致 ThreadLocal 不会被回收,从而导致内存泄漏。弱引用 ThreadLocal 不会内存泄漏,对应的 value 在下一次 ThreadLocalMap 调用 set、get、remove 方法时被清除,这算是最优的解决方案。

ThreadLocal为什么要使用弱引用和内存泄露问题

Map中的key为一个threadlocal实例.如果使用强引用当ThreadLocal对象假设为ThreadLocal@123456的引用被回收了ThreadLocalMap本身依然还持有ThreadLocal@123456的强引用如果没有手动删除这个key则ThreadLocal@123456不会被回收所以只要当前线程不消亡ThreadLocalMap引用的那些对象就不会被回收可以认为这导致Entry内存泄漏。

如果使用弱引用那指向ThreadLocal@123456对象的引用就两个ThreadLocal强引用和ThreadLocalMap中Entry的弱引用。一旦ThreadLocal强引用被回收则指向ThreadLocal@123456的就只有弱引用了在下次gc的时候这个ThreadLocal@123456就会被回收。

虽然上述的弱引用解决了key也就是线程的ThreadLocal能及时被回收但是value却依然存在内存泄漏的问题。当把threadlocal实例置为null以后,没有任何强引用指向threadlocal实例,所以threadlocal将会被gc回收.map里面的value却没有被回收.而这块value永远不会被访问到了. 所以存在着内存泄露,因为存在一条从current thread连接过来的强引用.只有当前thread结束以后, current thread就不会存在栈中,强引用断开, Current Thread, Map, value将全部被GC回收.所以当线程的某个localThread使用完了马上调用threadlocal的remove方法,就不会发生这种情况了。

另外其实只要这个线程对象及时被gc回收这个内存泄露问题影响不大但在threadLocal设为null到线程结束中间这段时间不会被回收的就发生了我们认为的内存泄露。最要命的是线程对象不被回收的情况这就发生了真正意义上的内存泄露。比如使用线程池的时候线程结束是不会销毁的会再次使用就可能出现内存泄露。

HashSet和HashMap

HashSet的value存的是一个static finial PRESENT = newObject()。而HashSet的remove是使用HashMap实现,则是map.remove而map的移除会返回value,如果底层value都是存null,显然将无法分辨是否移除成功。

Boolean占几个字节

未精确定义字节。Java语言表达式所操作的boolean值在编译之后都使用Java虚拟机中的int数据类型来代替而boolean数组将会被编码成Java虚拟机的byte数组每个元素boolean元素占8位。

阻塞非阻塞与同步异步的区别

  1. 同步和异步关注的是消息通信机制,所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。你打电话问书店老板有没有《分布式系统》这本书,如果是同步通信机制,书店老板会说,你稍等,”我查一下"然后开始查啊查等查好了可能是5秒也可能是一天告诉你结果返回结果。而异步通信机制书店老板直接告诉你我查一下啊查好了打电话给你然后直接挂电话了不返回结果。然后查好了他会主动打电话给你。在这里老板通过“回电”这种方式来回调。
  2. 阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果,如果是非阻塞式调用,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果。在这里阻塞与非阻塞与是否同步异步无关。跟老板通过什么方式回答你结果无关。

Java SPI

由于双亲委派模型损失了一丢丢灵活性。就比如java.sql.Driver这个东西。JDK只能提供一个规范接口而不能提供实现。提供实现的是实际的数据库提供商。提供商的库总不能放JDK目录里吧。Java从1.6搞出了SPI就是为了优雅的解决这类问题——JDK提供接口供应商提供服务。编程人员编码时面向接口编程然后JDK能够自动找到合适的实现。

Spring

什么是三级缓存

  1. 第一级缓存单例缓存池singletonObjects。
  2. 第二级缓存早期提前暴露的对象缓存earlySingletonObjects。属性还没有值对象也没有被初始化
  3. 第三级缓存singletonFactories单例对象工厂缓存。

三级缓存详解:根据 Spring 源码写一个带有三级缓存的 IOC

Spring如何解决循环依赖问题

Spring使用了三级缓存解决了循环依赖的问题。在populateBean()给属性赋值阶段里面Spring会解析你的属性并且赋值当发现A对象里面依赖了B此时又会走getBean方法但这个时候你去缓存中是可以拿的到的。因为我们在对createBeanInstance对象创建完成以后已经放入了缓存当中所以创建B的时候发现依赖A直接就从缓存中去拿此时B创建完A也创建完一共执行了4次。至此Bean的创建完成最后将创建好的Bean放入单例缓存池中。

BeanFactory和ApplicationContext的区别

  1. BeanFactory是Spring里面最低层的接口提供了最简单的容器的功能只提供了实例化对象和拿对象的功能。
  2. ApplicationContext应用上下文继承BeanFactory接口它是Spring的一各更高级的容器提供了更多的有用的功能。如国际化访问资源载入多个有继承关系上下文 使得每一个上下文都专注于一个特定的层次消息发送、响应机制AOP等。
  3. BeanFactory在启动的时候不会去实例化Bean中有从容器中拿Bean的时候才会去实例化。ApplicationContext在启动的时候就把所有的Bean全部实例化了。它还可以为Bean配置lazy-init=true来让Bean延迟实例化

动态代理的实现方式AOP的实现方式

  1. JDK动态代理利用反射机制生成一个实现代理接口的匿名类在调用具体方法前调用InvokeHandler来处理。
  2. CGlib动态代理利用ASM开源的Java字节码编辑库操作字节码开源包将代理对象类的class文件加载进来通过修改其字节码生成子类来处理。
  3. 区别JDK代理只能对实现接口的类生成代理CGlib是针对类实现代理对指定的类生成一个子类并覆盖其中的方法这种通过继承类的实现方式不能代理final修饰的类。

@Transactional错误使用失效场景

  1. @Transactional 在private上当标记在protected、private、package-visible方法上时不会产生错误但也不会表现出为它指定的事务配置。可以认为它作为一个普通的方法参与到一个public方法的事务中。
  2. @Transactional 的事务传播方式配置错误。
  3. @Transactional 注解属性 rollbackFor 设置错误Spring默认抛出了未检查unchecked异常继承自 RuntimeException 的异常)或者 Error才回滚事务其他异常不会触发回滚事务。
  4. 同一个类中方法调用,导致@Transactional失效由于使用Spring AOP代理造成的因为只有当事务方法被当前类以外的代码调用时才会由Spring生成的代理对象来管理。
  5. 异常被 catch 捕获导致@Transactional失效。
  6. 数据库引擎不支持事务。

Spring的的事务传播机制

  1. REQUIRED默认常用支持使用当前事务如果当前事务不存在创建一个新事务。eg:方法B用REQUIRED修饰方法A调用方法B如果方法A当前没有事务方法B就新建一个事务若还有C则B和C在各自的事务中独立执行如果方法A有事务方法B就加入到这个事务中当成一个事务。
  2. SUPPORTS支持使用当前事务如果当前事务不存在则不使用事务。
  3. MANDATORY强制支持使用当前事务如果当前事务不存在则抛出Exception。
  4. REQUIRES_NEW常用创建一个新事务如果当前事务存在把当前事务挂起。eg:方法B用REQUIRES_NEW修饰方法A调用方法B不管方法A上有没有事务方法B都新建一个事务在该事务执行。
  5. NOT_SUPPORTED无事务执行如果当前事务存在把当前事务挂起。
  6. NEVER无事务执行如果当前有事务则抛出Exception。
  7. NESTED嵌套事务如果当前事务存在那么在嵌套的事务中执行。如果当前事务不存在则表现跟REQUIRED一样。

Spring中Bean的生命周期

  1. 实例化 Instantiation
  2. 属性赋值 Populate
  3. 初始化 Initialization
  4. 销毁 Destruction

Spring的后置处理器

  1. BeanPostProcessorBean的后置处理器主要在bean初始化前后工作。before和after两个回调中间只处理了init-method
  2. InstantiationAwareBeanPostProcessor继承于BeanPostProcessor主要在实例化bean前后工作TargetSource的AOP创建代理对象就是通过该接口实现
  3. BeanFactoryPostProcessorBean工厂的后置处理器在bean定义(bean definitions)加载完成后bean尚未初始化前执行。
  4. BeanDefinitionRegistryPostProcessor继承于BeanFactoryPostProcessor。其自定义的方法postProcessBeanDefinitionRegistry会在bean定义(bean definitions)将要加载bean尚未初始化前真执行即在BeanFactoryPostProcessor的postProcessBeanFactory方法前被调用。

Spring MVC的工作流程源码层面

参考文章:自己写个Spring MVC

消息队列

为什么需要消息队列

解耦,异步处理,削峰/限流

Kafka的文件存储机制

Kafka中消息是以topic进行分类的生产者通过topic向Kafka broker发送消息消费者通过topic读取数据。然而topic在物理层面又能以partition为分组一个topic可以分成若干个partition。partition还可以细分为segment一个partition物理上由多个segment组成segment文件由两部分组成分别为“.index”文件和“.log”文件分别表示为segment索引文件和数据文件。这两个文件的命令规则为partition全局的第一个segment从0开始后续每个segment文件名为上一个segment文件最后一条消息的offset值。

Kafka 如何保证可靠性

如果我们要往 Kafka 对应的主题发送消息,我们需要通过 Producer 完成。前面我们讲过 Kafka 主题对应了多个分区,每个分区下面又对应了多个副本;为了让用户设置数据可靠性, Kafka 在 Producer 里面提供了消息确认机制。也就是说我们可以通过配置来决定消息发送到对应分区的几个副本才算消息发送成功。可以在定义 Producer 时通过 acks 参数指定。这个参数支持以下三种值:

  • acks = 0意味着如果生产者能够通过网络把消息发送出去那么就认为消息已成功写入 Kafka 。在这种情况下还是有可能发生错误,比如发送的对象无能被序列化或者网卡发生故障,但如果是分区离线或整个集群长时间不可用,那就不会收到任何错误。在 acks=0 模式下的运行速度是非常快的(这就是为什么很多基准测试都是基于这个模式),你可以得到惊人的吞吐量和带宽利用率,不过如果选择了这种模式, 一定会丢失一些消息。
  • acks = 1意味若 Leader 在收到消息并把它写入到分区数据文件(不一定同步到磁盘上)时会返回确认或错误响应。在这个模式下,如果发生正常的 Leader 选举,生产者会在选举时收到一个 LeaderNotAvailableException 异常,如果生产者能恰当地处理这个错误,它会重试发送悄息,最终消息会安全到达新的 Leader 那里。不过在这个模式下仍然有可能丢失数据,比如消息已经成功写入 Leader但在消息被复制到 follower 副本之前 Leader发生崩溃。
  • acks = all这个和 request.required.acks = -1 含义一样):意味着 Leader 在返回确认或错误响应之前会等待所有同步副本都收到悄息。如果和min.insync.replicas 参数结合起来,就可以决定在返回确认前至少有多少个副本能够收到悄息,生产者会一直重试直到消息被成功提交。不过这也是最慢的做法,因为生产者在继续发送其他消息之前需要等待所有副本都收到当前的消息。

Kafka消息是采用Pull模式还是Push模式

Kafka最初考虑的问题是customer应该从brokes拉取消息还是brokers将消息推送到consumer也就是pull还push。在这方面Kafka遵循了一种大部分消息系统共同的传统的设计producer将消息推送到brokerconsumer从broker拉取消息。push模式下当broker推送的速率远大于consumer消费的速率时consumer恐怕就要崩溃了。最终Kafka还是选取了传统的pull模式。Pull模式的另外一个好处是consumer可以自主决定是否批量的从broker拉取数据。Pull有个缺点是如果broker没有可供消费的消息将导致consumer不断在循环中轮询直到新消息到t达。为了避免这点Kafka有个参数可以让consumer阻塞知道新消息到达。

Kafka是如何实现高吞吐率的

  1. 顺序读写kafka的消息是不断追加到文件中的这个特性使kafka可以充分利用磁盘的顺序读写性能
  2. 零拷贝:跳过“用户缓冲区”的拷贝,建立一个磁盘空间和内存的直接映射,数据不再复制到“用户态缓冲区”
  3. 文件分段kafka的队列topic被分为了多个区partition每个partition又分为多个段segment所以一个队列中的消息实际上是保存在N多个片段文件中
  4. 批量发送Kafka允许进行批量发送消息先将消息缓存在内存中然后一次请求批量发送出去
  5. 数据压缩Kafka还支持对消息集合进行压缩Producer可以通过GZIP或Snappy格式对消息集合进行压缩

Kafka判断一个节点还活着的两个条件

  1. 节点必须可以维护和 ZooKeeper 的连接Zookeeper 通过心跳机制检查每个节点的连接
  2. 如果节点是个 follower,他必须能及时的同步 leader 的写操作,延时不能太久

Dubbo

Dubbo的容错机制

  1. 失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。可通过 retries="2" 来设置重试次数
  2. 快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
  3. 失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。
  4. 失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
  5. 并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks="2" 来设置最大并行数。
  6. 广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息

Dubbo注册中心挂了还可以继续通信么

可以,因为刚开始初始化的时候,消费者会将提供者的地址等信息拉取到本地缓存,所以注册中心挂了可以继续通信。

Dubbo框架设计结构

  1. 服务接口层:该层是与实际业务逻辑相关的,根据服务提供方和服务消费方的业务设计对应的接口和实现。
  2. 配置层对外配置接口以ServiceConfig和ReferenceConfig为中心可以直接new配置类也可以通过spring解析配置生成配置类。
  3. 服务代理层服务接口透明代理生成服务的客户端Stub和服务器端Skeleton以ServiceProxy为中心扩展接口为ProxyFactory。
  4. 服务注册层封装服务地址的注册与发现以服务URL为中心扩展接口为RegistryFactory、Registry和RegistryService。可能没有服务注册中心此时服务提供方直接暴露服务。
  5. 集群层封装多个提供者的路由及负载均衡并桥接注册中心以Invoker为中心扩展接口为Cluster、Directory、Router和LoadBalance。将多个服务提供方组合为一个服务提供方实现对服务消费方来透明只需要与一个服务提供方进行交互。
  6. 监控层RPC调用次数和调用时间监控以Statistics为中心扩展接口为MonitorFactory、Monitor和MonitorService。
  7. 远程调用层封将RPC调用以Invocation和Result为中心扩展接口为Protocol、Invoker和Exporter。Protocol是服务域它是Invoker暴露和引用的主功能入口它负责Invoker的生命周期管理。Invoker是实体域它是Dubbo的核心模型其它模型都向它靠扰或转换成它它代表一个可执行体可向它发起invoke调用它有可能是一个本地的实现也可能是一个远程的实现也可能一个集群实现。
  8. 信息交换层封装请求响应模式同步转异步以Request和Response为中心扩展接口为Exchanger、ExchangeChannel、ExchangeClient和ExchangeServer。
  9. 网络传输层抽象mina和netty为统一接口以Message为中心扩展接口为Channel、Transporter、Client、Server和Codec。
  10. 数据序列化层可复用的一些工具扩展接口为Serialization、 ObjectInput、ObjectOutput和ThreadPool。

操作系统

进程和线程

  1. 进程是操作系统资源分配的最小单位线程是CPU任务调度的最小单位。一个进程可以包含多个线程所以进程和线程都是一个时间段的描述是CPU工作时间段的描述不过是颗粒大小不同。
  2. 不同进程间数据很难共享,同一进程下不同线程间数据很易共享。
  3. 每个进程都有独立的代码和数据空间,进程要比线程消耗更多的计算机资源。线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。
  4. 进程间不会相互影响,一个线程挂掉将导致整个进程挂掉。
  5. 系统在运行的时候会为每个进程分配不同的内存空间而对线程而言除了CPU外系统不会为线程分配内存线程所使用的资源来自其所属进程的资源线程组之间只能共享资源。

多线程和单线程

线程不是越多越好假如你的业务逻辑全部是计算型的CPU密集型,不涉及到IO并且只有一个核心。那肯定一个线程最好多一个线程就多一点线程切换的计算CPU不能完完全全的把计算能力放在业务计算上面线程越多就会造成CPU利用率用在业务计算的时间/总的时间下降。但是在WEB场景下业务并不是CPU密集型任务而是IO密集型的任务一个线程是不合适如果一个线程在等待数据时把CPU的计算能力交给其他线程这样也能充分的利用CPU资源。但是线程数量也要有个限度一般线程数有一个公式最佳启动线程数=[任务执行时间/(任务执行时间-IO等待时间)]*CPU内核数超过这个数量CPU要进行多余的线程切换从而浪费计算能力低于这个数量CPU要进行IO等待从而造成计算能力不饱和。总之就是要尽可能的榨取CPU的计算能力。如果你的CPU处于饱和状态并且没有多余的线程切换浪费那么此时就是你服务的完美状态如果再加大并发量势必会造成性能上的下降。

进程的组成部分

进程由进程控制块PCB、程序段、数据段三部分组成。

进程的通信方式

  1. 无名管道半双工的即数据只能在一个方向上流动只能用于具有亲缘关系的进程之间的通信可以看成是一种特殊的文件对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
  2. FIFO命名管道FIFO是一种文件类型可以在无关的进程之间交换数据与无名管道不同FIFO有路径名与之相关联它以一种特殊设备文件形式存在于文件系统中。
  3. 消息队列消息队列是消息的链接表存放在内核中。一个消息队列由一个标识符即队列ID来标识。
  4. 信号量:信号量是一个计数器,信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
  5. 共享内存:共享内存指两个或多个进程共享一个给定的存储区,一般配合信号量使用。

进程间五种通信方式的比较

  1. 管道:速度慢,容量有限,只有父子进程能通讯。
  2. FIFO任何进程间都能通讯但速度慢。
  3. 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题。
  4. 信号量:不能传递复杂消息,只能用来同步。
  5. 共享内存区:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存。

内存管理有哪几种方式

  1. 块式管理把主存分为一大块、一大块的当所需的程序片断不在主存时就分配一块主存空间把程序片断load入主存就算所需的程序片度只有几个字节也只能把这一块分配给它。这样会造成很大的浪费平均浪费了50的内存空间但是易于管理。
  2. 页式管理:把主存分为一页一页的,每一页的空间要比一块一块的空间小很多,显然这种方法的空间利用率要比块式管理高很多。
  3. 段式管理:把主存分为一段一段的,每一段的空间又要比一页一页的空间小很多,这种方法在空间利用率上又比页式管理高很多,但是也有另外一个缺点。一个程序片断可能会被分为几十段,这样很多时间就会被浪费在计算每一段的物理地址上。
  4. 段页式管理结合了段式管理和页式管理的优点。将程序分成若干段每个段分成若干页。段页式管理每取一数据要访问3次内存。

页面置换算法

  1. 最佳置换算法OPT只具有理论意义的算法用来评价其他页面置换算法。置换策略是将当前页面中在未来最长时间内不会被访问的页置换出去。
  2. 先进先出置换算法FIFO简单粗暴的一种置换算法没有考虑页面访问频率信息。每次淘汰最早调入的页面。
  3. 最近最久未使用算法LRU算法赋予每个页面一个访问字段用来记录上次页面被访问到现在所经历的时间t每次置换的时候把t值最大的页面置换出去(实现方面可以采用寄存器或者栈的方式实现)。
  4. 时钟算法clock(也被称为是最近未使用算法NRU)页面设置一个访问位并将页面链接为一个环形队列页面被访问的时候访问位设置为1。页面置换的时候如果当前指针所指页面访问为为0那么置换否则将其置为0循环直到遇到一个访问为位0的页面。
  5. 改进型Clock算法在Clock算法的基础上添加一个修改位替换时根究访问位和修改位综合判断。优先替换访问位和修改位都是0的页面其次是访问位为0修改位为1的页面。
  6. LFU最少使用算法LFU设置寄存器记录页面被访问次数每次置换的时候置换当前访问次数最少的。

操作系统中进程调度策略有哪几种

  1. 先来先服务调度算法FCFS队列实现非抢占先请求CPU的进程先分配到CPU可以作为作业调度算法也可以作为进程调度算法按作业或者进程到达的先后顺序依次调度对于长作业比较有利.
  2. 最短作业优先调度算法SJF作业调度算法算法从就绪队列中选择估计时间最短的作业进行处理直到得出结果或者无法继续执行平均等待时间最短但难以知道下一个CPU区间长度缺点不利于长作业未考虑作业的重要性运行时间是预估的并不靠谱.
  3. 优先级调度算法(可以是抢占的,也可以是非抢占的)优先级越高越先分配到CPU相同优先级先到先服务存在的主要问题是低优先级进程无穷等待CPU会导致无穷阻塞或饥饿.
  4. 时间片轮转调度算法(可抢占的)按到达的先后对进程放入队列中然后给队首进程分配CPU时间片时间片用完之后计时器发出中断暂停当前进程并将其放到队列尾部循环 ;队列中没有进程被分配超过一个时间片的CPU时间除非它是唯一可运行的进程。如果进程的CPU区间超过了一个时间片那么该进程就被抢占并放回就绪队列。

死锁的4个必要条件

  1. 互斥条件:一个资源每次只能被一个线程使用;
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
  3. 不剥夺条件:进程已经获得的资源,在未使用完之前,不能强行剥夺;
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何避免(预防)死锁

  1. 破坏“请求和保持”条件:让进程在申请资源时,一次性申请所有需要用到的资源,不要一次一次来申请,当申请的资源有一些没空,那就让线程等待。不过这个方法比较浪费资源,进程可能经常处于饥饿状态。还有一种方法是,要求进程在申请资源前,要释放自己拥有的资源。
  2. 破坏“不可抢占”条件:允许进程进行抢占,方法一:如果去抢资源,被拒绝,就释放自己的资源。方法二:操作系统允许抢,只要你优先级大,可以抢到。
  3. 破坏“循环等待”条件:将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序提出(指定获取锁的顺序,顺序加锁)。

计算机网路

Get和Post区别

  1. Get是不安全的因为在传输过程数据被放在请求的URL中Post的所有操作对用户来说都是不可见的。
  2. Get传送的数据量较小这主要是因为受URL长度限制Post传送的数据量较大一般被默认为不受限制。
  3. Get限制Form表单的数据集的值必须为ASCII字符而Post支持整个ISO10646字符集。
  4. Get执行效率却比Post方法好。Get是form提交的默认方法。
  5. GET产生一个TCP数据包POST产生两个TCP数据包。非必然客户端可灵活决定

Http请求的完全过程

  1. 浏览器根据域名解析IP地址DNS,并查DNS缓存
  2. 浏览器与WEB服务器建立一个TCP连接
  3. 浏览器给WEB服务器发送一个HTTP请求GET/POST一个HTTP请求报文由请求行request line、请求头部headers、空行blank line和请求数据request body4个部分组成。
  4. 服务端响应HTTP响应报文报文由状态行status line、相应头部headers、空行blank line和响应数据response body4个部分组成。
  5. 浏览器解析渲染

tcp和udp区别

  1. TCP面向连接UDP是无连接的即发送数据之前不需要建立连接。
  2. TCP提供可靠的服务。也就是说通过TCP连接传送的数据无差错不丢失不重复且按序到达;UDP尽最大努力交付即不保证可靠交付。
  3. TCP面向字节流实际上是TCP把数据看成一连串无结构的字节流UDP是面向报文的UDP没有拥塞控制因此网络出现拥塞不会使源主机的发送速率降低对实时应用很有用如IP电话实时视频会议等
  4. 每一条TCP连接只能是点到点的UDP支持一对一一对多多对一和多对多的交互通信。
  5. TCP首部开销20字节UDP的首部开销小只有8个字节。
  6. TCP的逻辑通信信道是全双工的可靠信道UDP则是不可靠信道。

tcp和udp的优点

  • TCP的优点 可靠,稳定 TCP的可靠体现在TCP在传递数据之前会有三次握手来建立连接而且在数据传递时有确认、窗口、重传、拥塞控制机制在数据传完后还会断开连接用来节约系统资源。 TCP的缺点 慢,效率低,占用系统资源高,易被攻击 TCP在传递数据之前要先建连接这会消耗时间而且在数据传递时确认机制、重传机制、拥塞控制机制等都会消耗大量的时间而且要在每台设备上维护所有的传输连接事实上每个连接都会占用系统的CPU、内存等硬件资源。 而且因为TCP有确认机制、三次握手机制这些也导致TCP容易被人利用实现DOS、DDOS、CC等攻击。
  • UDP的优点比TCP稍安全 UDP没有TCP的握手、确认、窗口、重传、拥塞控制等机制UDP是一个无状态的传输协议所以它在传递数据时非常快。没有TCP的这些机制UDP较TCP被攻击者利用的漏洞就要少一些。但UDP也是无法避免攻击的比如UDP Flood攻击…… UDP的缺点 不可靠,不稳定 因为UDP没有TCP那些可靠的机制在数据传递时如果网络质量不好就会很容易丢包。 基于上面的优缺点,那么: 什么时候应该使用TCP 当对网络通讯质量有要求的时候比如整个数据要准确无误的传递给对方这往往用于一些要求可靠的应用比如HTTP、HTTPS、FTP等传输文件的协议POP、SMTP等邮件传输的协议。 在日常生活中常见使用TCP协议的应用如下 浏览器用的HTTP FlashFXP用的FTP Outlook用的POP、SMTP Putty用的Telnet、SSH QQ文件传输。什么时候应该使用UDP 当对网络通讯质量要求不高的时候要求网络通讯速度能尽量的快这时就可以使用UDP。 比如日常生活中常见使用UDP协议的应用如下 QQ语音 QQ视频 TFTP。

三次握手

  • 第一次握手建立连接时客户端发送syn包syn=x到服务器并进入SYN_SENT状态等待服务器确认SYN同步序列编号Synchronize Sequence Numbers
  • 第二次握手服务器收到syn包必须确认客户的SYNack=x+1同时自己也发送一个SYN包syn=y即SYN+ACK包此时服务器进入SYN_RECV状态
  • 第三次握手客户端收到服务器的SYN+ACK包向服务器发送确认包ACK(ack=y+1此包发送完毕客户端和服务器进入ESTABLISHEDTCP连接成功状态完成三次握手。

为什么不能两次握手

TCP是一个双向通信协议通信双方都有能力发送信息并接收响应。如果只是两次握手 至多只有连接发起方的起始序列号能被确认, 另一方选择的序列号则得不到确认

四次挥手

  1. 客户端进程发出连接释放报文并且停止发送数据。释放数据报文首部FIN=1其序列号为seq=u等于前面已经传送过来的数据的最后一个字节的序号加1此时客户端进入FIN-WAIT-1终止等待1状态。 TCP规定FIN报文段即使不携带数据也要消耗一个序号。
  2. 服务器收到连接释放报文发出确认报文ACK=1ack=u+1并且带上自己的序列号seq=v此时服务端就进入了CLOSE-WAIT关闭等待状态。TCP服务器通知高层的应用进程客户端向服务器的方向就释放了这时候处于半关闭状态即客户端已经没有数据要发送了但是服务器若发送数据客户端依然要接受。这个状态还要持续一段时间也就是整个CLOSE-WAIT状态持续的时间。
  3. 客户端收到服务器的确认请求后此时客户端就进入FIN-WAIT-2终止等待2状态等待服务器发送连接释放报文在这之前还需要接受服务器发送的最后的数据
  4. 服务器将最后的数据发送完毕后就向客户端发送连接释放报文FIN=1ack=u+1由于在半关闭状态服务器很可能又发送了一些数据假定此时的序列号为seq=w此时服务器就进入了LAST-ACK最后确认状态等待客户端的确认。
  5. 客户端收到服务器的连接释放报文后必须发出确认ACK=1ack=w+1而自己的序列号是seq=u+1此时客户端就进入了TIME-WAIT时间等待状态。注意此时TCP连接还没有释放必须经过2MSL最长报文段寿命的时间后当客户端撤销相应的TCB后才进入CLOSED状态。
  6. 服务器只要收到了客户端发出的确认立即进入CLOSED状态。同样撤销TCB后就结束了这次的TCP连接。可以看到服务器结束TCP连接的时间要比客户端早一些

为什么连接的时候是三次握手,关闭的时候却是四次握手

因为当Server端收到Client端的SYN连接请求报文后可以直接发送SYN+ACK报文。其中ACK报文是用来应答的SYN报文是用来同步的。但是关闭连接时当Server端收到FIN报文时很可能并不会立即关闭SOCKET所以只能先回复一个ACK报文告诉Client端"你发的FIN报文我收到了"。只有等到我Server端所有的报文都发送完了我才能发送FIN报文因此不能一起发送。故需要四步握手。

数据结构与算法

排序算法

  1. 冒泡排序
  2. 选择排序:选择排序与冒泡排序有点像,只不过选择排序每次都是在确定了最小数的下标之后再进行交换,大大减少了交换的次数
  3. 插入排序将一个记录插入到已排序的有序表中从而得到一个新的记录数增1的有序表
  4. 快速排序:通过一趟排序将序列分成左右两部分,其中左半部分的的值均比右半部分的值小,然后再分别对左右部分的记录进行排序,直到整个序列有序。
int partition(int a[],  int low, int high){
    int key = a[low];
    while( low < high ){
        while(low < high && a[high] >= key) high--;
        a[low] = a[high];
        while(low < high && a[low] <= key) low++;
        a[high] = a[low];
    }
    a[low] = key;
    return low;
}
void quick_sort(int a[], int low, int high){
    if(low >= high) return;
    int keypos = partition(a, low, high);
    quick_sort(a, low, keypos-1);
    quick_sort(a, keypos+1, high);
}
  1. 堆排序将待排序序列构造成一个大顶堆此时整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆这样会得到n个元素的次小值。如此反复执行便能得到一个有序序列了。
public class Test {

    public void sort(int[] arr) {
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            adjustHeap(arr, i, arr.length);
        }
        for (int j = arr.length - 1; j > 0; j--) {
            swap(arr, 0, j);
            adjustHeap(arr, 0, j);
        }

    }

    public void adjustHeap(int[] arr, int i, int length) {
        int temp = arr[i];
        for (int k = i * 2 + 1; k < length; k = k * 2 + 1) {
            if (k + 1 < length && arr[k] < arr[k + 1]) {
                k++;
            }
            if (arr[k] > temp) {
                arr[i] = arr[k];
                i = k;
            } else {
                break;
            }
        }
        arr[i] = temp;
    }

    public void swap(int[] arr, int a, int b) {
        int temp = arr[a];
        arr[a] = arr[b];
        arr[b] = temp;
    }

    public static void main(String[] args) {
        int[] arr = {9, 8, 7, 6, 5, 4, 3, 2, 1};
        new Test().sort(arr);
        System.out.println(Arrays.toString(arr));
    }
}
  1. 希尔排序:先将整个待排记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录基本有序时再对全体记录进行一次直接插入排序。
  2. 归并排序:把有序表划分成元素个数尽量相等的两半,把两半元素分别排序,两个有序表合并成一个

实际问题

高并发系统的设计与实现

在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。

  • 缓存缓存比较好理解在大型高并发系统中如果没有缓存数据库将分分钟被爆系统也会瞬间瘫痪。使用缓存不单单能够提升系统访问速度、提高并发访问量也是保护数据库、保护系统的有效方式。大型网站一般主要是“读”缓存的使用很容易被想到。在大型“写”系统中缓存也常常扮演者非常重要的角色。比如累积一些数据批量写入内存里面的缓存队列生产消费以及HBase写数据的机制等等也都是通过缓存提升系统的吞吐量或者实现系统的保护措施。甚至消息中间件你也可以认为是一种分布式的数据缓存。
  • 降级:服务降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。降级往往会指定不同的级别,面临不同的异常等级执行不同的处理。根据服务方式:可以拒接服务,可以延迟服务,也有时候可以随机服务。根据服务范围:可以砍掉某个功能,也可以砍掉某些模块。总之服务降级需要根据不同的业务需求采用不同的降级策略。主要的目的就是服务虽然有损但是总比没有好。
  • 限流:限流可以认为服务降级的一种,限流就是限制系统的输入和输出流量已达到保护系统的目的。一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要限制流量并采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。

负载均衡算法:

  1. 轮询
  2. 加权轮询
  3. 随机算法
  4. 一致性Hash

常见的限流算法:

常见的限流算法有计数器、漏桶和令牌桶算法。漏桶算法在分布式环境中消息中间件或者Redis都是可选的方案。发放令牌的频率增加可以提升整体数据处理的速度而通过每次获取令牌的个数增加或者放慢令牌的发放速度和降低整体数据处理速度。而漏桶不行因为它的流出速率是固定的程序处理速度也是固定的。

秒杀并发情况下库存为负数问题

  1. for update显示加锁
  2. 把update语句写在前边先把数量-1之后select出库存如果>-1就commit,否则rollback。
update products set quantity = quantity-1 WHERE id=3;
select quantity from products WHERE id=3 for update;
  1. update语句在更新的同时加上一个条件
quantity = select quantity from products WHERE id=3;
update products set quantity = ($quantity-1) WHERE id=3 and queantity = $quantity;