# 面试问题整理
## ZooKeeper
### CAP定理:
一个分布式系统不可能同时满足以下三种,一致性( C:Consistency) ,可用性( A:Available) ,分区容错性( P:Partition Tolerance) .在此ZooKeeper保证的是CP, ZooKeeper不能保证每次服务请求的可用性, 在极端环境下, ZooKeeper可能会丢弃一些请求, 消费者程序需要重新请求才能获得结果。另外在进行leader选举时集群都是不可用, 所以说, ZooKeeper不能保证服务可用性。( Base理论CA强一致性和最终一致性)
### 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。
## 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的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
对于单线程阻塞式的Redis, Pipeline可以满足批量的操作, 把多个命令连续的发送给Redis Server, 然后一一解析响应结果。Pipelining可以提高批量处理性能, 提升的原因主要是TCP连接中减少了“交互往返”的时间。pipeline 底层是通过把所有的操作封装成流, redis有定义自己的出入输出流。在 sync() 方法执行操作,每次请求放在队列里面,解析响应包。
## Mysql
### 事务的基本要素
1. 原子性:事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行
2. 一致性:事务开始前和结束后,数据库的完整性约束没有被破坏。
3. 隔离性:同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。
4. 持久性:事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。
### 事务的并发问题
1. 脏读: 事务A读取了事务B更新的数据, 然后B回滚操作, 那么A读取到的数据是脏数据
2. 不可重复读: 事务A多次读取同一数据, 事务B在事务A多次读取的过程中, 对数据作了更新并提交, 导致事务A多次读取同一数据时, 结果不一致。
3. 幻读: A事务读取了B事务已经提交的新增数据。注意和不可重复读的区别, 这里是新增, 不可重复读是更改( 或删除) 。select某记录是否存在, 不存在, 准备插入此记录, 但执行 insert 时发现此记录已存在,无法插入,此时就发生了幻读。
### MySQL事务隔离级别
事务隔离级别 | 脏读 | 不可重复读 | 幻读
读未提交 | 是 | 是 |是
不可重复读 | 否 | 是 |是
可重复读 | 否 | 否 |是
串行化 | 否 | 否 |否
在MySQL可重复读的隔离级别中并不是完全解决了幻读的问题, 而是解决了读数据情况下的幻读问题。而对于修改的操作依旧存在幻读问题, 就是说MVCC对于幻读的解决时不彻底的。
通过索引加锁, 间隙锁, next key lock可以解决幻读的问题。
### Mysql的逻辑结构
* 最上层的服务类似其他CS结构, 比如连接处理, 授权处理。
* 第二层是Mysql的服务层, 包括SQL的解析分析优化, 存储过程触发器视图等也在这一层实现。
* 最后一层是存储引擎的实现, 类似于Java接口的实现, Mysql的执行器在执行SQL的时候只会关注API的调用, 完全屏蔽了不同引擎实现间的差异。比如Select语句, 先会判断当前用户是否拥有权限, 其次到缓存( 内存) 查询是否有相应的结果集, 如果没有再执行解析sql, 优化生成执行计划, 调用API执行。
### 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的操作执行到磁盘上的文件上。不管是否提交成功我都记录, 你要是回滚了, 那我连回滚的修改也记录。它确保了事务的持久性。
* MVCC多版本并发控制是MySQL中基于乐观锁理论实现隔离级别的方式, 用于读已提交和可重复读取隔离级别的实现。在MySQL中, 会在表中每一条数据后面添加两个字段: 最近修改该行数据的事务ID, 指向该行( undolog表中) 回滚段的指针。Read View判断行的可见性, 创建一个新事务时, copy一份当前系统中的活跃事务列表。意思是, 当前不应该被本事务看到的其他事务id列表。
* binlog由Mysql的Server层实现,是逻辑日志,记录的是sql语句的原始逻辑, 比如"把id='B' 修改为id = ‘ B2’ 。binlog会写入指定大小的物理文件中,是追加写入的,当前文件写满则会创建新的文件写入。 产生:事务提交的时候,一次性将事务中的sql语句,按照一定的格式记录到binlog中。用于复制和恢复在主从复制中, 从库利用主库上的binlog进行重播(执行日志中记录的修改逻辑),实现主从同步。业务数据不一致或者错了, 用binlog恢复。
### 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堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例, 几乎所有的对象实例都在这里分配内存。
### 分代回收
HotSpot JVM把年轻代分为了三部分: 1个Eden区和2个Survivor区( 分别叫from和to) 。一般情况下, 新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后, 如果仍然存活, 将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC, 年龄就会增加1岁, 当它的年龄增加到一定程度时, 就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的,所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在GC开始的时候, 对象只会存在于Eden区和名为“From”的Survivor区, Survivor区“To”是空的。紧接着进行GC, Eden区中所有存活的对象都会被复制到“To”, 而在“From”区中, 仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中, 没有达到阈值的对象会被复制到“To”区域。经过这次GC后, Eden区和From区已经被清空。这个时候, “From”和“To”会交换他们的角色, 也就是新的“To”就是上次GC前的“From”, 新的“From”就是上次GC前的“To”。不管怎样, 都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程, 直到“To”区被填满, “To”区被填满之后, 会将所有对象移动到年老代中。
### 常见的垃圾回收机制
1. 引用计数法:引用计数法是一种简单但速度很慢的垃圾回收技术。每个对象都含有一个引用计数器,当有引用连接至对象时,引用计数加1。当引用离开作用域或被置为null时,引用计数减1。虽然管理引用计数的开销不大,但这项开销在整个程序生命周期中将持续发生。垃圾回收器会在含有全部对象的列表上遍历,当发现某个对象引用计数为0时,就释放其占用的空间。
2. 可达性分析算法: 这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点, 从这些节点开始向下搜索, 搜索所走过的路径称为引用链, 当一个对象到GC Roots没有任何引用链相连( 用图论的话来说, 就是从GC Roots到这个对象不可达) 时, 则证明此对象是不可用的。
### 哪些对象可以作为GC Roots
1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
2. 方法区中类静态属性引用的对象。
3. 方法区中常量引用的对象。
4. 本地方法栈中JNI( 即一般说的Native方法) 引用的对象。
### GC中Stop the world( STW)
在执行垃圾收集算法时, 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程序代码( 字节码) 。
### 双亲委派模型
双亲委派的意思是如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。
### 什么情况下需要开始类加载过程的第一个阶段加载
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中
## 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数组, 在写操作put, remove, 扩容的时候, 会对Segment加锁, 所以仅仅影响这个Segment, 不同的Segment还是可以并发的, 所以解决了线程的安全问题, 同时又采用了分段锁也提升了并发的效率。在JDK1.8版本中, ConcurrentHashMap摒弃了Segment的概念, 而是直接用Node数组+链表+红黑树的数据结构来实现, 并发控制使用Synchronized和CAS来操作, 整个看起来就像是优化过且线程安全的HashMap。
### HashMap如果我想要让自己的Object作为K应该怎么办
1. 重写hashCode()是因为需要计算存储数据的存储位置, 需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能, 这样虽然能更快但可能会导致更多的Hash碰撞;
2. 重写equals()方法, 需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x, x.equals(null)必须返回false的这几个特性, 目的是为了保证key在哈希表中的唯一性;
### 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的回收计数器) , 一个是线程标记( 当前线程是谁加锁的) , 一个是阻塞队列。
### 死锁的4个必要条件
1. 互斥条件:一个资源每次只能被一个线程使用;
2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
3. 不剥夺条件:进程已经获得的资源,在未使用完之前,不能强行剥夺;
4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
指定获取锁的顺序可避免死锁
### 如何指定多个线程的执行顺序
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: 拒绝策略。当任务太多来不及处理, 如何拒绝任务。
### 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位。
## 消息队列
### 为什么需要消息队列
解耦,异步处理,削峰/限流
### 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将消息推送到broker, consumer从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注册中心挂了还可以继续通信么
可以,因为刚开始初始化的时候,消费者会将提供者的地址等信息拉取到本地缓存,所以注册中心挂了可以继续通信。
## 计算机网路
### 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 body) 4个部分组成。
4. 服务端响应HTTP响应报文, 报文由状态行( status line) 、相应头部( headers) 、空行( blank line) 和响应数据( response body) 4个部分组成。
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包, 必须确认客户的SYN( ack=x+1) , 同时自己也发送一个SYN包( syn=y) , 即SYN+ACK包, 此时服务器进入SYN_RECV状态;
* 第三次握手: 客户端收到服务器的SYN+ACK包, 向服务器发送确认包ACK(ack=y+1) , 此包发送完毕, 客户端和服务器进入ESTABLISHED( TCP连接成功) 状态, 完成三次握手。
### 为什么不能两次握手
TCP是一个双向通信协议, 通信双方都有能力发送信息, 并接收响应。如果只是两次握手, 至多只有连接发起方的起始序列号能被确认, 另一方选择的序列号则得不到确认
### 四次挥手
1. 客户端进程发出连接释放报文, 并且停止发送数据。释放数据报文首部, FIN=1, 其序列号为seq=u( 等于前面已经传送过来的数据的最后一个字节的序号加1) , 此时, 客户端进入FIN-WAIT-1( 终止等待1) 状态。 TCP规定, FIN报文段即使不携带数据, 也要消耗一个序号。
2. 服务器收到连接释放报文, 发出确认报文, ACK=1, ack=u+1, 并且带上自己的序列号seq=v, 此时, 服务端就进入了CLOSE-WAIT( 关闭等待) 状态。TCP服务器通知高层的应用进程, 客户端向服务器的方向就释放了, 这时候处于半关闭状态, 即客户端已经没有数据要发送了, 但是服务器若发送数据, 客户端依然要接受。这个状态还要持续一段时间, 也就是整个CLOSE-WAIT状态持续的时间。
3. 客户端收到服务器的确认请求后, 此时, 客户端就进入FIN-WAIT-2( 终止等待2) 状态, 等待服务器发送连接释放报文( 在这之前还需要接受服务器发送的最后的数据) 。
4. 服务器将最后的数据发送完毕后, 就向客户端发送连接释放报文, FIN=1, ack=u+1, 由于在半关闭状态, 服务器很可能又发送了一些数据, 假定此时的序列号为seq=w, 此时, 服务器就进入了LAST-ACK( 最后确认) 状态, 等待客户端的确认。
5. 客户端收到服务器的连接释放报文后, 必须发出确认, ACK=1, ack=w+1, 而自己的序列号是seq=u+1, 此时, 客户端就进入了TIME-WAIT( 时间等待) 状态。注意此时TCP连接还没有释放, 必须经过2∗ ∗ MSL( 最长报文段寿命) 的时间后, 当客户端撤销相应的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);
}
```
5. 堆排序: 假设序列有n个元素,先将这n建成大顶堆, 然后取堆顶元素, 与序列第n个元素交换, 然后调整前n-1元素, 使其重新成为堆, 然后再取堆顶元素, 与第n-1个元素交换, 再调整前n-2个元素...直至整个序列有序。
6. 希尔排序:先将整个待排记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录基本有序时再对全体记录进行一次直接插入排序。
7. 归并排序:把有序表划分成元素个数尽量相等的两半,把两半元素分别排序,两个有序表合并成一个
## 实际问题
### 高并发系统的限流详解及实现
在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。
* 缓存: 缓存比较好理解, 在大型高并发系统中, 如果没有缓存数据库将分分钟被爆, 系统也会瞬间瘫痪。使用缓存不单单能够提升系统访问速度、提高并发访问量, 也是保护数据库、保护系统的有效方式。大型网站一般主要是“读”, 缓存的使用很容易被想到。在大型“写”系统中, 缓存也常常扮演者非常重要的角色。比如累积一些数据批量写入, 内存里面的缓存队列( 生产消费) , 以及HBase写数据的机制等等也都是通过缓存提升系统的吞吐量或者实现系统的保护措施。甚至消息中间件, 你也可以认为是一种分布式的数据缓存。
* 降级:服务降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。降级往往会指定不同的级别,面临不同的异常等级执行不同的处理。根据服务方式:可以拒接服务,可以延迟服务,也有时候可以随机服务。根据服务范围:可以砍掉某个功能,也可以砍掉某些模块。总之服务降级需要根据不同的业务需求采用不同的降级策略。主要的目的就是服务虽然有损但是总比没有好。
* 限流:限流可以认为服务降级的一种,限流就是限制系统的输入和输出流量已达到保护系统的目的。一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要限制流量并采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。
### 常见的限流算法:
常见的限流算法有计数器、漏桶和令牌桶算法。漏桶算法在分布式环境中消息中间件或者Redis都是可选的方案。发放令牌的频率增加可以提升整体数据处理的速度, 而通过每次获取令牌的个数增加或者放慢令牌的发放速度和降低整体数据处理速度。而漏桶不行, 因为它的流出速率是固定的, 程序处理速度也是固定的。
### 秒杀并发情况下库存为负数问题
1. for update显示加锁
2. 把udpate语句写在前边, 先把数量-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;
```