19、【对线面试官】kafka基础

今天要不来聊聊消息队列吧?我看你项目不少地方都写到Kafka了.你简单说明下你使用Kafka的场景吧

  1. 使用消息队列的目的总的来说可以有三种情况:解耦、异步和削峰

  2. 比如举我项目的例子吧,我现在维护一个消息管理平台系统,对外提供发送接口给各个业务方调用

  3. 他们调用接口之后,实际上『不是同步』下发了消息。

  4. 在接口处理层只是把该条消息放到了消息队列上,随后就直接返回结果给接口调用者了。

  5. 这样的好处就是:

    1)接口的吞吐量会大幅度提高(因为未做真正实际调用,接口RT会非常低)【异步】

    2)即便有大批量的消息调用接口都不会让系统受到影响(流量由消息队列承载)【削峰】

有点抽象,再举个实际案例?

  1. 又比如说,我这边还有个项目是广告订单归因工程,主要做的事情就是得到订单数据,给各个业务广告计算对应的佣金。

  2. 订单的数据是从消息队列里取出的

  3. 这样设计的好处就是:

    1)交易团队的同学只要把订单消息写到消息队列,该订单数据的Topic由各个业务方自行消费使用【解耦】【异步】

    2)即便下单QPS猛增,对下游业务无太大的感知(因为下游业务只消费消息队列的数据,不会直接影响到机器性能)【削峰】

那我想问下,你觉得为什么消息队列能削峰?或者换个问法,为什么Kafka能承载这么大的QPS?

  1. 消息队列「最核心的功能就是把生产的数据存储起来,然后给各个业务把数据再读取出来。

  2. 跟我们处理请求时不一样,我们在业务处理时可能会调别人的接口,可能会需要去查数据库…等等等一系列的操作才行

  3. 这些业务操作都是非常耗时的,像Kafka在「存储」和「读取」这个过程中又做了很多的优化

  4. 举几个例子,比如说:

    1)我们往一个Topic发送消息或者读取消息时,实际内部是多个Partition在处理【并行】

    2)在存储消息时,Kafka内部是顺序写磁盘的,并且利用了操作系统的缓冲区来提高性能【append+cache】

    3)在读写数据中也减少CPU拷贝的次数【零拷贝】

嗯,你既然提到减少CPU拷贝的次数,可以给我说下这项技术吗?

  1. 嗯,可以的,其实就是零拷贝技术。

  2. 比如我们正常调用read函数时,会发生以下的步骤(以读磁盘的数据为例):

    1)DMA把磁盘数据拷贝到读内核缓存区

    2)CPU把读内核缓冲区的数据拷贝到用户空间

  3. 正常调用write函数时,会发生以下的步骤(数据写到网卡为例):

    1)CPU把用户空间的数据拷贝到Socket内核缓存区

    2)DMA把Socket内核缓冲区的数据拷贝到网卡

  4. 可以发现完成「一次读写」需要2次DMA拷贝,2次CPU拷贝。

  5. 而DMA拷贝是省不了的,所谓的零拷贝技术就是把CPU的拷贝给省掉

  6. 并且为了避免用户进程直接操作内核,保证内核安全,应用程序在调用系统函数时,会发生上下文切换(上述的过程一共会发生4次)

  7. 目前零拷贝技术主要有:mmap和sendfile

  8. 比如说:mmap是将读缓冲区的地址和用户空间的地址进行映射,实现读内核缓冲区和应用缓冲区共享

  9. 从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝

  10. 使用mmap的后一次读写就可以简化为:、

    一、DMA把硬盘数据拷贝到读内核缓冲

    二、CPU把读内核缓存区拷贝至Socket内核缓冲区。

    三、DMA把Socket内核缓冲区拷贝至网

  11. 由于读内核缓冲区与用户空间做了映射,所以会省了一次CPU拷贝

  12. 而sendfile+DMA Scatter/Gather!则是把读内核缓存区的文件描述符/长度信息发到Socket内核缓冲区,实现CPU零拷贝

  13. 使用sendfile+DMA Scatter/Gather一次读写就可以简化为:

    1)DMA把硬盘数据拷贝至读内核缓冲区

    2)CPU把读缓冲区的文件描述符和长度信息发到Socket缓冲区。

    3)DMA根据文件描述符和数据长度从读内核缓冲区把数据拷贝至网卡

  14. 回到kafka上吧

  15. 从Producer-》Broker,Kafka是把网卡的数据持久化硬盘,用的是mmap(从2次
    CPU拷贝减至1次)

  16. 从Broker-》Consumer,Kafka是从硬盘的数据发送至网卡,用的是sendFile(实
    现CPU零拷贝)

总结

Kafka能这么快的原因就是实现了并行、充分利用操作系统cache、顺序写和零拷贝

l

20、【对线面试官】使用kafka会考虑什么问题

你提到了你这边会从交易的消息报获取到订单的数据,然后做业务的处理;也提到了你用的是Kafka,我想问下,Kafka会丢数据吗?

  1. 嗯,使用Kafkal时,有可能会有以下场景会丢消息

  2. 比如说,我们用Producer发消息至Broke的时候,就有可能会丢消息

  3. 如果你不想丢消息,那在发送消息的时候,需要选择带有callBack的api进行发送

  4. 其实就意味着,如果你发送成功了,会回调告诉你已经发送成功了。如果失败了,那收到回调之后自己在业务上做重试就好了。

  5. 等到把消息发送到Brokerl以后,也有可能丢消息

  6. 一般我们的线上环境都是集群环境下嘛,但可能你发送的消息后broker就挂了,这时挂掉的broker还没来得及把数据同步给别的broker,数据就自然就丢了

  7. 发送到Broker之后,也不能保证数据就一定不丢了,毕竟Broker会把数据存储到磁盘之前,走的是操作系统缓存

  8. 也就是异步刷盘这个过程还有可能导致数据会丢

  9. 嗯,到这里其实我已经说了三个场景了,分别是:producer-》broker,broker-》broker之间同步,以及broker-》磁盘

  10. 要解决上面所讲的问题也比较简单,这块也没什么好说的…

  11. 不想丢数据,那就使用带有callback的api设置acks、retries、factor等等些参数来保证Producer发送的消息不会丢就好啦。

一般来说,还是client消费broker丢消息的场景比较多;那你们在消费数据的时候是怎么保证数据的可靠性的呢?

  1. 首先,要想client端消费数据不能丢,肯定是不能使用autoCommit的,所以必须是手动提交的。

  2. 我们这边是这样实现的:

    一、从Kafka拉取消息、(一次批量拉取500条,这里主要看配置)
    二、为每条拉取的消息分配一个msgld(递增)
    三、将msgld存入内存队列(sortSet)中
    四、使用Map存储msgld.与msg(有offset相关的信息)的映射关系

    五、当业务处理完消息后,ack时,获取当前处理的消息nsgld,然后从sortSet删除该msgld(此时代表已经处理过了)
    六、接着与sortSet队列的首部第一个ld比较(其实就是最小的msgld),如果当前msgld<=sort Set第一个ID,则提交当前offset

    七、系统即便挂了,在下次重启时就会从sortSet队首的消息开始拉取,实现至少处理一次语义
    八、会有少量的消息重复,但只要下游做好幂等就OK了

嗯,你也提到了幂等,你们是怎么实现幂等性的呢?

  1. 嗯,还是以处理订单消息为例好了。
  2. 幂等Key我们由订单编号+订单状态所组成(一笔订单的状态只会处理一次)
  3. 在处理之前,我们首先会去查Redis:是否存在该Key,如果存在,则说明我们已经处理过了,直接丢掉
  4. 如果Redis没处理过,则继续往下处理,最终的逻辑是将处理过的数据插入到业务DB上,再到最后把幂等Key插入到Redis上
  5. 显然,单纯通过Redis是无法保证幂等的
  6. 所以,Redis其实只是一个「前置」处理,最终的幂等性是依赖数据库的唯一Key来保证的(唯一Key实际上也是订单编号+状态)
  7. 而插入DB是依赖事务的,所以是没问题的
  8. 总的来说,就是通过Redis做前置处理,DB唯一索引做最终保证来实现幂等性的

你们那边遇到过顺序消费的问题吗?

  1. 嗯,也是有的,我举个例子

  2. 订单的状态比如有支付、确认收货、完成等等,而订单下还有计费、退款的消息报

  3. 理论上来说,支付的消息报肯定要比退款消息报先到嘛,但程序处理的过程中可不一定的嘛

  4. 所以在这边也是有消费顺序的问题(先处理了支付,才能退款啊)

  5. 但在广告场景下不是「强顺序」的,只要保证最终一致性就好了。

  6. 所以我们这边处理「乱序」消息的实现是这样的:

    1)宽表:将每一个订单状态,单独分出一个或多个独立的字段。消息来时只更新对应的字段就好,消息只会存在短暂的状态不一致问题,但是状态最终是一致的

    2)消息补偿机制:另一个进行消费相同topicl的数据,消息落盘,延迟处理。将消息与DB进行对比,如果发现数据不一致,再重新发送消息至主进程处理

    3)还有部分场景,可能我们只需要把相同userld/orderld.发送到相同的partition(因为一个partition由一个Consumer消费),又能解决大部分消费顺序的问题了呢。

l

21、【对线面试官】Mysql索引

我看你简历上写了MySQL,对MySQL InnoDB引擎的索引了解吗?

  1. 嗯啊,使用索引可以加快查询速度,其实上就是将无序的数据变成有序(有序就能加快检索速度)
  2. 在InnoDB引擎中,索引的底层数据结构是**B+树**

那为什么不使用红黑树或者B树呢?

  1. MySQL的数据是存储在硬盘的,在查询时一般是不能「一次性」把全部数据加载到内存中

  2. 红黑树是「二叉查找树」的变种,一个Node节点只能存储一个Key和一个Value

  3. B和B+树跟红黑树不一样,它们算是「多路搜索树」,相较于「二叉搜索树」而言,一个Node节点可以存储的信息会更多,「多路搜索树」的高度会比「二叉搜索树」更低。

  4. 了解了区别之后,其实就很容易发现,在数据不能一次加载至内存的场景下,数据需要被检索出来

  5. 选择B或B+树的理由就很充分了(一个Node节点存储信息更多(相较于二叉搜索树),树的高度更低,树的高度影响检索的速度)

  6. B+树相对于B树而言,它又有两种特性。

    1)B+树非叶子节点不存储数据,在相同的数据量下,B+树更加矮壮。(这个应该不用多解释了,数据都存储在叶子节点上,非叶子节点的存储能存储更多的索引,所以整棵树就更加矮壮)

    2)B+树叶子节点之间组成一个链表,方便于遍历查询(遍历操作在MySQL中比较常见)

  7. 我稍微解释一下吧,你可以脑补下画面

  8. 我们在MySQL InnoDB引擎下,每创建一个索引,相当于生成了一颗B+树。

  9. 如果该索引是「聚集(聚簇)索引」,那当前B+树的叶子节点存储着「主键和当前行的数据」

  10. 如果该索引是「非聚簇索引小」,那当前B+树的叶子节点存储着「主键和当前索引列值」

  11. 比如写了一句sql:select*from user where id>=10,那只要定位到id为10的记录,然后在叶子节点之间通过遍历链表(叶子节点组成的链表),即可找到往后的记录了。

  12. 由于B树是会在非叶子节点也存储数据,要遍历的时候可能就得跨层检索,相对麻烦些。

  13. 基于树的层级以及业务使用场景的特性,所以MySQL选择了B+树作为索引的底层数据结构。

  14. 对于哈希结构,其实InnoDB引擎是「自适应」哈希索引的(hash索引的创建由lnnoDB存储引擎自动优化创建,我们是干预不了)

你知道什么是回表吗?

  1. 所谓的回表其实就是,当我们使用非聚簇索引查询数据时,检索出来的数据可能包含其他列
  2. 但走的索引树叶子节点只能查到当前列值以及主键ID,所以需要根据主键ID再去查一遍数据,得到SQL所需的列
  3. 举个例子,我这边建了给订单号ID建了个索引,但我的SQL是:select orderld,orderName from orderdetail where orderld = 123
  4. SQL走订单ID索引,但在订单ID的索引树的叶子节点只有orderld和ld,而我们还想检索出orderName,所以MySQL会拿到ID再去查出orderName给我们返回,这种操作就叫回表

如何避免回表

  1. 想要避免回表,可以使用覆盖索引
  2. 所谓的覆盖索引,实际上就是你想要查出的列刚好在叶子节点上都存在,比如我建了orderld和orderName.联合索引l,刚好我需要查询也是orderld和orderName,这些数据都存在索引树的叶子节点上,就不需要回表操作了。

既然你也提到了联合索引,我想问下你了解最左匹配原则吗

  1. 嗯,要说明这个概念,还是举例子比较容易
  2. 如有索引(a,b,c,d),查询条件a=1and b=2 and c>3 and d=4,则会在每个节点依次命中a、b、c,无法命中d
  3. 先匹配最左边的,索引只能用于查找key是否存在(相等),遇到范围查询(>、<、between、like左匹配)等就不能进一步匹配了,后续退化为线性查找
  4. 这就是最左匹配原则

嗯嗯,我还想问下你们主键是怎么生成的?

主键就自增的

那假设我不用MySQL自增的主键,你觉得会有什么问题呢?

  1. 首先主键得保证它的唯一性和空间尽可能短吧,这两块是需要考虑的。
  2. 另外,由于索引的特性(有序),如果生成像uuid类似的主键,那插入的的性能是比自增的要差的
  3. 因为生成的uuid,在插入时有可能需要移动磁盘块(比如,块内的空间在当前时刻已经存储满了,但新生成的uuid需要插入已满的块内,就需要移动块的数据)
l

这次我想问下,你是怎么理解InnoDB引擎中的事务的?

  1. 在我的理解下,事务可以使「一组操作」要么全部成功,要么全部失败
  2. 事务其目的是为了「保证数据最终的一致性」。
  3. 举个例子,我给你发支付宝转了888块红包。那自然我的支付宝余额会扣减888块,你的支付宝余额会增加888块。
  4. 而事务就是保证我的余额扣减跟你的余额增添是同时成功或者同时失败的,这样这次转账就正常了

嗯,那你了解事务的几大特性吗?

嗯,就是ACID嘛,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。

  1. 原子性指的是:当前事务的操作要么同时成功,要么同时失败。原子性由undo Iog日志来保证,因为undo log记载着数据修改前的信息。

    1)比如我们要insert一条数据了,那undo Iog会记录的一条对应的delete日志。我们要update一条记录时,那undo log会记录之前的「旧值」的update记录。

    2)如果执行事务过程中出现异常的情况,那执行「回滚」。InnoDB引擎就是利用undo log记录下的数据,来将数据「恢复」到事务开始之前

  2. 隔离性指的是:在事务「并发」执行时,他们内部的操作不能互相干扰。

    1)如果多个事务可以在同一时刻操作同一份数据,那么就会可能会产生脏读、重复读、幻读的问题。

    2)于是,事务与事务之间需要存在「一定」的隔离。在nnoDB引擎中,定义了四种隔离级别供我们使用:分别是:read uncommit(读未提交)、read commit(读已提交)、repeatable read(可重复复读)、serializable(串行)

    3)不同的隔离级别对事务之间的隔离性是不一样的(级别越高事务隔离性越好,但性能就越低),而隔离性是由MySQL的各种锁来实现的,只是它屏蔽了加锁的细节。

  3. 持久性指的就是:一旦提交了事务,它对数据库的改变就应该是永久性的。说白了就是,会将数据持久化在硬盘上。

    1)而持久性由redo log日志来保证,当我们要修改数据时,MySQL是先把这条记录所在的「页」找到,然后把该页加载到内存中,将对应记录进行修改。

    2)为了防止内存修改完了,MySQL就挂掉了(如果内存改完,直接挂掉,那这次的修改相当于就丢失了)。

    3)MySQL引入了redo log,内存写完了,然后会写一份redo log,这份redo log记载着这次在某个页上做了什么修改。

    4)即便MySQL在中途挂了,我们还可以根据redo log:来对数据进行恢复。

    5)redo log是顺序写的,写入速度很快。并且它记录的是物理修改(xxxx页做了xxx修改),文件的体积很小,恢复速度也很快。

  4. 「一致性」可以理解为我们使用事务的「目的」,而「隔离性」「原子性」「持久性」均是为了保障「一致性」的手段,保证一致性需要由应用程序代码来保证

    1)比如,如果事务在发生的过程中,出现了异常情况,此时你就得回滚事务,而不是强行提交事务来导致数据不一致。

刚才你也提到了隔离性嘛,然后你说在MySQL中有四种隔离级别,能分别来介绍下吗?

  1. 嗯,为了讲清楚隔离级别,我顺带来说下MySQL锁相关的知识吧。

    1)在InnoDB引擎下,按锁的粒度分类,可以简单分为行锁和表锁。

    2)行锁实际上是作用在索引之上的(索引上次已经说过了,这里就不赘述了)

    3)当我们的SQL命中了索引,那锁住的就是命中条件内的索引节点(这种就是行锁),如果没有命中索引,那我们锁的就是整个索引树(表锁)。

    4)简单来说就是:锁住的是整棵树还是某几个节点,完全取决于SQL条件是否有命中到对应的索引节点。

    5)而行锁又可以简单分为读锁(共享锁、S锁)和写锁(排它锁、X锁)。

    6)读锁是共享的,多个事务可以同时读取同一个资源,但不允许其他事务修改。写锁是排他的,写锁会阻塞其他的写锁和读锁。

  2. 我现在就再回到隔离级别上吧,就直接以例子来说明啦。

    1)首先来说下read uncommit(读未提交)。比如说:A向B转账,A执行了转账语句,但A还没有提交事务,B读取数据,发现自己账户钱变多了!B跟A说,我已经收到钱了。A回滚事务【rollback】,等B再查看账户的钱时,发现钱并没有多。

    ​ (1)简单的定义就是:事务B读取到了事务A还没提交的数据,这种用专业术语来说叫做「脏读」。

    ​ (2)对于锁的维度而言,其实就是在read uncommit隔离级别下,读不会加任何锁,而写会加排他锁。读什么锁都不加,这就让排他锁无法排它了。

    ​ (3)我们又知道,对于更新操作而言,lnnODB是肯定会加写锁的(数据库是不可能允许在同一时间,更新同一条记录的)。而读操作,如果不加任何锁,那就会造成上面的脏读。

    ​ (4)脏读在生产环境下肯定是无法接受的,那如果读加锁的话,那意味着:当更新数据的时,就没办法读取了,这会极大地降低数据库性能。

    • 在MySQL InnoDB引擎层面,又有新的解决方案(解决加锁后读写性能问题),叫做MVCC(Multi-Version Concurrency Control)多版本并发控制

    • 在MVCC下,就可以做到读写不阻塞,且避免了类似脏读这样的问题。那MVCC是怎么做的呢?

    • MVCC通过生成数据快照(Snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取

    (5)回到事务隔离级别下,针对于read commit(读已提交)隔离级别,它生成的就是语句级快照,而针对于repeatable read(可重复读),它生成的就是事务级的快照。

    2)前面提到过read uncommit隔离级别下会产生脏读,而read commit(读已提交)隔离级别解决了脏读

    ​ (1)思想其实很简单:在读取的时候生成一个”版本号”,等到其他事务commit了之后,才会读取最新已commit的”版本号”数据。

    ​ (2)比如说:事务A读取了记录(生成版本号),事务B修改了记录(此时加了写锁),事务A再读取的时候,是依据最新的版本号来读取的(当事务B执行commit了之后,会生成一个新的版本号),如果事务B还没有commit.那事务A读取的还是之前版本号的数据。

    ​ (3)通过「版本」的概念,这样就解决了脏读的问题,而通过「版本」又可以对应快照的数据。read commit(读已提交)解决了脏读。

    3)read commit(读已提交)解决了脏读,但也会有其他并发的问题。「不可重复读」:一个事务读取到另外一个事务已经提交的数据,也就是说一个事务可以看到其他事务所做的修改。

    ​ (1)不可重复读的例子:A查询数据库得到数据,B去修改数据库的数据,导致A多次查询数据库的结果都不一样【危害:A每次查询的结果都是受B的影响的】

    ​ (2)了解MVCC基础之后,就很容易想到repeatable read(可重复复读)隔离级别是怎么避免不可重复读的问题了(前面也提到了)。

    ​ (3)repeatable read(可重复复读)隔离级别是「事务级别」的快照!每次读取的都是「当前事务的版本」,即使当前数据被其他事务修改了(commit),也只会读取当前事务版本的数据。

    ​ (4)在InnoDB引擎下的的repeatable read(可重复复读)隔离级别下,在MVCC下,快照读,已经解决了幻读的问题(因为它是读历史版本的数据)

    ​ (5)而如果是当前读(比如select*from table for update),则需要配合间隙锁来解决幻读的问题。

    4)剩下的就是serializable(串行)隔离级别了,它的最高的隔离级别,相当于不允许事务的并发,事务与事务之间执行是串行的,它的效率最低,但同时也是最安全的。

我看你提到了MVCC了,不妨来说下他的原理?

  1. MVCC的主要是通过read view和undo log来实现的
  2. undo log前面也提到了,它会记录修改数据之前的信息,事务中的原子性就是通过undo log:来实现的。所以,有undo log可以帮我们找到「版本」的数据
  3. 而read view实际上就是在查询时,lnnoDB会生成一个read view,read view有几个重要的字段,看下去就懂了
  4. 分别是:trx ids(尚未提交commit的事务版本号集合),low limit id(下一次要生成的事务D值),low limit id(尚未提交版本号的事务D最小值)以及creator trx id(当前的事务版本号)
  5. 在每行数据有两列隐藏的字段,分别是DB_TRX_ID(记录着当前ID)以及DB_ROLL _PTR(指向上一个版本数据在undolog里的位置指针)
  6. 垫到这了,很容易就发现,MVCC其实就是靠「比对版本」来实现读写不阻塞,而版本的数据存在于undo log中。
  7. 而针对于不同的隔离级别(read commit和repeatable read),无非就是read commit隔离级别下,每次都获取一个新的read view,repeatable read隔离级别则每次事务只获取一个read view

总结

  • 事务为了保证数据的最终一致性

  • 事务有四大特性,分别是原子性、一致性、隔离性、持久性

    • 原子性由undo log保证
    • 持久性由redo log 保证
    • 隔离性由数据库隔离级别供我们选择,分别有read uncommit,read commit,repeatable read,serializable
    • 一致性是事务的目的,一致性由应用程序来保证
  • 事务并发会存在各种问题,分别有脏读、重复读、幻读问题。上面的不同隔离级别可以解决掉由于并发事务所造成的问题,而隔离级别实际上就是由MySQL锁来实现的

  • 频繁加锁会导致数据库性能低下,引入了MVCC多版本控制来实现读写不阻塞,提高数据库性能

  • MVCC原理即通过read view 以及undo log来实现

l

23、【对线面试官】Mysql调优

要不你来讲讲你们对MySQL是怎么调优的?

  • 哇,这命题很大阿…我认为,对于开发者而言,对MySQL的调优重点一般是在「开发规范」、「数据库索引」又或者说解决线上慢查询上。
  • 而对于MySQL内部的参数调优,由专业的DBA来搞。

那你来聊聊你们平时开发的规范和索引这块,平时是怎么样的吧。

  • 嗯,首先,我们在生产环境下,创建数据库表,都是在工单系统下完成的(那就自然需要DBA审批)

  • 如果在创建表时检测到没有创建索引,那就会直接提示warning

  • 理论上来说,如果表有一定的数据量,那就应该要创建对应的索引

  • 从数据库查询数据需要注意的地方还是蛮多的,其中很多都是平时积累来的比如说:

    • 1.是否能使用「覆盖索引」,减少「回表」所消耗的时间。意味着,我们在select的时候,一定要指明对应的列,而不是select
    • 2.考虑是否组建「联合索引小」,如果组建「联合索引小」,尽量将区分度最高的放在最左边,并且需要考虑「最左匹配原则」
    • 3.对索引进行函数操作或者表达式计算会导致索引失效
    • 4.利用子查询优化超多分页场景。比如limit offset,n在MySQL是获取offset+n的记录,再返回n条。而利用子查询则是查出n条,通过ID检索对应的记录出来,提高查询效率。
    • 5.通过explain命令来查看SQL的执行计划,看看自己写的SQL是否走了索引,走了什么索引。通过show profile来查看SQL对系统资源的损耗情况(不过一般还是比较少用到的)
    • 6.在开启事务后,在事务内尽可能只操作数据库,并有意识地减少锁的持有时间(比如在事务内需要插入&&修改数据,那可以先插入后修改。因为修改是更新操作,会加行锁。如果先更新,那并发下可能会导致多个事务的请求等待行锁释放)

嗯,你提到了事务,之前也讲过了事务的隔离级别嘛,那你线上用的是什么隔离级别?

  • 嗯,我们这边用的是Read Commit(读已提交),MySQL默认用的是Repeatable read(可重复读)
  • 选用什么隔离级别,主要看应用场景嘛,因为隔离级别越低,事务并发性能越高。
  • (一般互联网公司都选择Read Commit作为主要的隔离级别)
  • 像Repeatable read(可重复读)隔离级别,就有可能因为「间隙锁」导致的死锁问题。
  • 但,MySQL默认的隔离级别为Repeatable read。很大一部分原因是在最开始的时候,MySQL的binlog没有row模式,在read commit隔离级别下会存在「主从数据不一致」的问题
  • binlog记录了数据库表结构和表数据「变更」,比如update/delete/insert/truncate/create。在MySQL中,主从同步实际上就是应用了binlog:来实现的
  • 有了该历史原因,所以MySQL就将默认的隔离级别设置为Repeatable read

了解了,那我顺便想问下,你们遇到过类似的问题吗:即便走对了索引,线上查询还是慢。

  • 如果走对了索引,但查询还是慢,那一般来说就是表的数据量实在是太大了。
  • 首先,考虑能不能把「旧的数据」给”删掉”,对于我们公司而言,我们都会把数据同步到Hive,说明已经离线存储了一份了。
  • 那如果「旧的数据」已经没有查询的业务了,那最简单的办法肯定是”删掉”部分数据咯。数据量降低了,那自然,检索速度就快了…
  • 但,只有极少部分业务可以删掉数据
  • 随后,就考虑另一种情况,能不能在查询数据库之前,直接走一层缓存(Redis)。
    • 而走缓存的话,又要看业务能不能忍受读取的「非真正实时」的数据(毕竟Redis和MySQL的数据一致性需要保证),如果查询条件相对复杂且多变的话(涉及各种group by和sum),那走缓存也不是一种好的办法,维护起来就不方便了…
    • 再看看是不是有「字符串」检索的场景导致查询低效,如果是的话,可以考虑把表的数据导入至Elasticsearch类的搜索引擎,后续的线上查询就直接走Elasticsearch了。
    • MySQL->Elasticsearch需要有对应的同步程序(一般就是监听MySQL的binlog,解析binlog.后导入到Elasticsearch)
    • 如果还不是的话,那考虑要不要根据查询条件的维度,做相对应的聚合表,线上的请求就查询聚合表的数据,不走原表。
      • 比如,用户下单后,有一份订单明细,而订单明细表的量级太大。但在产品侧(前台)透出的查询功能是以「天」维度来展示的,那就可以将每个用户的每天数据聚合起来,在聚合表就是一个用户一天只有一条汇总后的数据。
      • 查询走聚合后的表,那速度肯定杠杠的(聚合后的表数据量肯定比原始表要少很多)
      • 思路大致的就是「以空间换时间」,相同的数据换别的地方也存储一份,提高查询效率

那我还想问下,除了读之外,写性能同样有瓶颈,怎么办?

  • 如果在MySQL读写都有瓶颈,那首先看下目前MySQL的架构是怎么样的。
  • 如果是单库的,那是不是可以考虑升级至主从架构,实现读写分离。
    • 简单理解就是:主库接收写请求,从库接收读请求。从库的数据由主库发送的binlog进而更新,实现主从数据一致(在一般场景下,主从的数据是通过异步来保证最终一致性的)
  • 如果在主从架构下,读写仍存在瓶颈,那就要考虑是否要分库分表了
    • 至少在我前公司的架构下,业务是区分的。流量有流量数据库,广告有广告的数据库,商品有商品的数据库
    • 所以,我这里讲的分库分表的含义是:在原来的某个库的某个表进而拆分。
      • 比如,现在我有一张业务订单表,这张订单表在广告库中,假定这张业务订单表已经有1亿数据量了,现在我要分库分表
        • 那就会将这张表的数据分至多个广告库以及多张表中
        • 分库分表的最明显的好处就是把请求进行均摊(本来单个库单个表有一亿的数据,那假设我分开8个库,那每个库1200+W的数据量,每个库下分8张表,那每张表就150W的数据量)。

你们是以什么来作为分库分表键的?

  • 按照我们这边的经验,一般来说是按照userld的(因为按照用户的维度查询比较多),如果要按照其他的维度进行查询,那还是参照上面的的思路(以空间换时间)。

那分库分表后的D是怎么生成的?

  • 这就涉及到分布式D生成的方式了,思路有很多。有借助MySQL自增的,有借助Redis自增的,有基于「雪花算法」自增的
  • 具体使用哪种方式,那就看公司的技术栈了,一般使用Redis和基于「雪花算法」实现用得比较多。
  • 至于为什么强调自增(还是跟索引是有序有关,前面已经讲过了,你应该还记得)

嗯,那如果我要分库分表了,迁移的过程是怎么样的呢

  • 我们一般采取「双写」的方式来进行迁移,大致步骤就是:
    • 1.增量的消息各自往新表和旧表写一份
    • 2.将旧表的数据迁移至新库
    • 3.迟早新表的数据都会追得上旧表(在某个节点上数据是同步的)
    • 4.校验新表和老表的数据是否正常(主要看能不能对得上)
    • 5.开启双读(一部分流量走新表,一部分流量走老表),相当于灰度上线的过程
    • 6.读流量全部切新表,停止老表的写入
  • 另外,提前准备回滚机制,临时切换失败能恢复正常业务以及有修数据的相关程序。

总结

  • 数据库表存在一定数据量,就需要有对应的索引
  • 发现慢查询时,检查是否走对索引,是否能用更好的索引进行优化查询速度,查看使用索引的姿势有没有问题
  • 当索引解决不了慢查询时,一般由于业务表的数据量太大导致,利用空间换时间的思想(NOSQL、聚合、冗余…)
  • 当读写性能均遇到瓶颈时,先考虑能否升级数据库架构即可解决问题,若不能则需要考虑分库分表
  • 分库分表虽然能解决掉读写瓶颈,但同时会带来各种问题,需要提前调研解决方案和踩坑
l

24、【对线面试官】为什么需要Java内存模型

今天想跟你聊聊Java内存模型,这块你了解过吗?

  • 嗯,我简单说下我的理解吧。那我就从为什么要有Java内存模型开始讲起吧

    • 单线程下,可见性/有序性/原子性都没问题

    • CPU为了效率,有了高速缓存、有了指令重排序等等,整块架构都变得复杂了。我们写的程序肯定也想要「充分」利用CPU的资源啊!于是乎,我们使用起了多线程

      • 多线程在意味着并发,并发就意味着我们需要考虑线程安全问题

        • 1.缓存数据不一致:多个线程同时修改「共享变量」,CPU核心下的高速缓存是「不共享」的,那多个cache与内存之间的数据同步该怎么做?
        • 2.CPU指令重排序在多线程下会导致代码在非预期下执行,最终会导致结果存在错误的情况。
      • 针对于「缓存不一致」问题,CPU也有其解决办法,常被大家所认识的有两种:

        1.使用「总线锁」:某个核心在修改数据的过程中,其他核心均无法修改内存中的数据。(类似于独占内存的概念,只要有CPU在修改,那别的CPU就得等待当前CPU释放)

        2.缓存一致性协议(MESI协议,其实协议有很多,只是举个大家都可能见过的)。MESI拆开英文是(Modified(修改状态)、Exclusive(独占状态)、Share(共享状态)、Invalid(无效状态))

      • 缓存一致性协议我认为可以理解为「缓存锁」,它针对的是「缓存行」(Cache Iine)进行”加锁”,所谓「缓存行」其实就是高速缓存存储的最小单位。

      • MESI协议的原理大概就是:当每个CPU读取共享变量之前,会先识别数据的「对象状态」(是修改、还是共享、还是独占、还是无效)。

      • 如果是独占,说明当前CPU将要得到的变量数据是最新的,没有被其他CPU所同时读取

      • 如果是共享,说明当前CPU将要得到的变量数据还是最新的,有其他的CPU在同时读取,但还没被修改

      • 如果是修改,说明当前CPU正在修改该变量的值,同时会向其他CPU发送该数据状态为invalid(无效)的通知,得到其他CPU响应后(其他CPU将数据状态从共享(share)变成invalid(无效),会当前CPU将高速缓存的数据写到主存,并把自己的状态从modify(修改)变成exclusive(独占)

      • 如果是无效,说明当前数据是被改过了,需要从主存重新读取最新的数据。

      • 其实MESI协议做的就是判断「对象状态」,根据「对象状态」做不同的策略移动

      • 关键就在于某个CPU在对数据进行修改时,需要「同步」通知其他CPU,表示这个数据被我修改了,你们不能用了。

      • 比较于「总线锁」,MESI协议的”锁粒度”更小了,性能那肯定会更高咯

但据我了解,CPU还有优化,你还知道吗?

  • 同步,意味着等待,等待意味着什么都干不了。CPU肯定不乐意啊,所以又优化了一把。
  • 优化思路就是从「同步」变成「异步」。
  • 在修改时会「同步」告诉其他CPU,而现在则把最新修改的值写到「store buffe」中,并通知其他CPU记得要改状态,随后CPU就直接返回干其他事了。
  • 等到收到其它CPU发过来的响应消息,再将数据更新到高速缓存中。
  • 其他CPU接收到invalid(无效)通知时,也会把接收到的消息放入「invalid queue」中,只要写到「invalid queue.」就会直接返回告诉修改数据的CPU已经将状态置为「invalid」
  • 而异步又会带来新问题:那我现在CPU修改完A值,写到「store buffer」了,CPU就可以干其他事了
  • 那如果该CPU又接收指令需要修改A值,但上一次修改的值还在「store buffer.」中呢,没修改至高速缓存呢。
  • 所以CPU在读取的时候,需要去「storebuffer.」看看存不存在,存在则直接取,不存在才读主存的数据。【Store Forwarding】
  • 好了,解决掉第一个异步带来的问题了。(相同的核心对数据进行读写,由于异步,很可能会导致第二次读取的还是旧值,所以首先读「store buffer」。
  • 那当然啊,那「异步化」会导致相同核心读写共享变量有问题,那当然也会导致「不同」核心读写共享变量有问题啊
  • CPU1修改了A值,已把修改后值写到「store buffer.」并通知CPU2对该值进行invalid(无效)操作,而CPU2可能还没收到invalid(无效)通知,就去做了其他的操作,导致CPU2读到的还是旧值。
  • 即便CPU2收到了invalid(无效)通知,但CPU1的值还没写到主存,那CPU2再次向主存读取的时候,还是旧值…
  • 变量之间很多时候是具有「相关性」(a=1;b=0;b=a),这对于CPU又是无感知的.…
  • 总体而言,由于CPU对「缓存一致性协议」进行的异步优化「store buffer」「invalid queue.」,很可能导致后面的指令很可能查不到前面指令的执行结果(各个指令的执行顺序非代码执行顺序),这种现象很多时候被称作「CPU乱序执行」
  • 为了解决乱序问题(也可以理解为可见性问题,修改完没有及时同步到其他的CPU),又引出了「内存屏障」的概念。
  • 「内存屏障」其实就是为了解决「异步优化」导致「CPU乱序执行」/「缓存不及时可见」的问题,那怎么解决的呢?嗯,就是把「异步优化」给”禁用“掉
  • 内存屏障可以分为三种类型:写屏障,读屏障以及全能屏障(包含了读写屏障)
  • 屏障可以简单理解为:在操作数据的时候,往数据插入一条”特殊的指令”。只要遇到这条指令,那前面的操作都得「完成」。
  • 那写屏障就可以这样理解:CPU当发现写屏障的指令时,会把该指令「之前」存在于「store Buffer.」所有写指令刷入高速缓存。
  • 通过这种方式就可以让CPU修改的数据可以马上暴露给其他CPU,达到「写操作」可见性的效果。
  • 那读屏障也是类似的:CPU当发现读屏障的指令时,会把该指令「之前」存在于「invalid queue」所有的指令都处理掉
  • 通过这种方式就可以确保当前CPU的缓存状态是准确的,达到「读操作」一定是读取最新的效果。

聊了半天,我一直在讲硬件/操作系统的东西,我要回到正题上了。

  • 由于不同CPU架构的缓存体系不一样、缓存一致性协议不一样、重排序的策略不一样、所提供的内存屏障指令也有差异,为了简化Java开发人员的工作。Java封装了一套规范,这套规范就是「Java内存模型」
  • 再详细地说,「Java内存模型」希望屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果。
  • 目的是解决多线程存在的原子性、可见性(缓存一致性)以及有序性问题。

那要不简单聊聊Java内存模型的规范和内容吧?

下次

总结

  • 并发问题产生的三大根源是「可见性」「有序性」「原子性」

  • 可见性:CPU架构下存在高速缓存,每个核心下的L1/L2高速缓存不共享(不可见)

  • 有序性:主要有三部分可能导致打破(编译器和处理器可以在不改变「单线程」程序语义的情况下,可以对代码语句顺序进行调整重新排序

    • 编译器优化导致重排序(编译器重排)
    • 指令集并行重排序(CPU原生重排)
    • 内存系统重排序(CPU架构下很可能有store buffer /invalid queue 缓冲区,这种「异步」很可能会导致指令重排)
  • 原子性:Java的一条语句往往需要多条 CPU 指令完成(i++),由于操作系统的线程切换很可能导致 i++ 操作未完成,其他线程“中途”操作了共享变量 i ,导致最终结果并非我们所期待的。

  • 在CPU层级下,为了解决「缓存一致性」问题,有相关的“锁”来保证,比如“总线锁”和“缓存锁”。

    • 总线锁是锁总线,对共享变量的修改在相同的时刻只允许一个CPU操作。
    • 缓存锁是锁缓存行(cache line),其中比较出名的是MESI协议,对缓存行标记状态,通过“同步通知”的方式,来实现(缓存行)数据的可见性和有序性
    • 但“同步通知”会影响性能,所以会有内存缓冲区(store buffer/invalid queue)来实现「异步」进而提高CPU的工作效率
    • 引入了内存缓冲区后,又会存在「可见性」和「有序性」的问题,平日大多数情况下是可以享受「异步」带来的好处的,但少数情况下,需要强「可见性」和「有序性」,只能”禁用”缓存的优化。
    • “禁用”缓存优化在CPU层面下有「内存屏障」,读屏障/写屏障/全能屏障,本质上是插入一条”屏障指令”,使得缓冲区(store buffer/invalid queue)在屏障指令之前的操作均已被处理,进而达到 读写 在CPU层面上是可见和有序的。
  • 不同的CPU实现的架构不一样,Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果。

l

25、【对线面试官】java从编译到执行,发生了什么

从基础先问起吧,你是怎么理解Java是一门「跨平台」的语言,也就是「一次编译,到处运行的」?

  • 因为有JVM
  • Java源代码会被编译为class文件,class文件是运行在JVM之上的。
  • 当我们日常开发安装JDK的时候,可以发现JDK是分「不同的操作系统」,JDK里是包含JVM的,所以Java依赖着JVM实现了『跨平台』
  • 通俗点来讲,JVM是面向操作系统的,它负责把Class字节码解释成系统所能识别的指令并执行,同时也负责程序运行时内存的管理。

那要不你来聊聊从源码文件(java)到代码执行的过程呗?

  • 简单总结的话,我认为就4个步骤:编译->加载->解释->执行

    • 编译:将源码文件编译成JVM可以解释的class文件。

      • 编译过程会对源代码程序做「语法分析」「语义分析」「注解处理」等等处理,最后才生成字节码文件。
      • 比如对泛型的擦除和我们经常用的Lombok就是在编译阶段干的。
    • 加载:将编译后的class文件加载到JVM中。

      • 在加载阶段又可以细化几个步骤:装载->连接->初始化

        • 【装载时机】为了节省内存的开销,并不会一次性把所有的类都装载至JVM,而是等到「有需要」的时候才进行装载(比如new和反射等等)
        • 【装载发生】class文件是通过「类加载器」装载到jvm中的,为了防止内存中出现多份同样的字节码,使用了双亲委派机制(它不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上)
        • 【装载规则】JDK中的本地方法类一般由根加载器(Bootstrp loader)装载,JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader)实现装载,而程序中的类文件则由系统加载器(AppClassLoader)实现装载。
      • 装载这个阶段它做的事情总结:查找并加载类的二进制数据,在JVM「堆」中创建一个java.lang.Class类的对象,并将类相关的信息存储在JVM「方法区」中

        • 通过「装载」这个步骤后,现在已经把class文件装载到JVM中了,并创建出对应的Class.对象以及类信息存储至方法区了。
      • 「连接」这个阶段它做的事情总结:对class的信息进行验证、为「类变量」分配内存空间并对其赋默认值。

        • 连接又可以细化为几个步骤:验证-》准备-》解析

          1.验证:验证类是否符合Java规范和JVM规范

          2.准备:为类的静态变量分配内存,初始化为系统的初始值

          3.解析:将符号引用转为直接引用的过程

        • 通过「连接」这个步骤后,现在已经对class信息做校验并分配了内存空间和默认值了。

      • 「初始化」阶段总结:为类的静态变量赋予正确的初始值。

        • 过程大概就是收集class的静态变量、静态代码块、静态方法至clinit()方法,随后从上往下开始执行。
        • 如果「实例化对象」则会调用方法对实例变量进行初始化,并执行对应的构造方法内的代码。
    • 解释:把字节码转换为操作系统识别的指令

      • 在解释阶段会有两种方式把字节码信息解释成机器指令码,一个是字节码解释器、一个是即时编译器(JIT)
      • JVM会对「热点代码」做编译,非热点代码直接进行解释。当JVM发现某个方法或代码块的运行特别频繁的时候,就有可能把这部分代码认定为「热点代码」
      • 使用「热点探测」来检测是否为热点代码。「热点探测」一般有两种方式,计数器和抽样。HotSpot使用的是「计数器」的方式进行探测,为每个方法准备了两类计数器:方法调用计数器和回边计数器
      • 这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。
      • 即时编译器把热点方法的指令码保存起来,下次执行的时候就无需重复的进行解释,直接执行缓存的机器语言
    • 执行:操作系统把解释器解析出来的指令码,调用系统的硬件执行最终的程序指令。

总结

  • Java跨平台因为有JVM屏蔽了底层操作系统

  • Java源码到执行的过程,从JVM的角度看可以总结为四个步骤:编译->加载->解释->执行

    • 「编译」经过 语法分析、语义分析、注解处理 最后才生成会class文件
    • 「加载」又可以细分步骤为:装载->连接->初始化。装载则把class文件装载至JVM,连接则校验class信息、分配内存空间及赋默认值,初始化则为变量赋值为正确的初始值。连接里又可以细化为:验证、准备、解析
    • 「解释」则是把字节码转换成操作系统可识别的执行指令,在JVM中会有字节码解释器和即时编译器。在解释时会对代码进行分析,查看是否为「热点代码」,如果为「热点代码」则触发JIT编译,下次执行时就无需重复进行解释,提高解释速度
    • 「执行」调用系统的硬件执行最终的程序指令

l

26、【对线面试官】双亲委派机制

接着上次的话题吧,要不你来详细讲讲双亲委派机制?

  • 上次提到了:class文件是通过「类加载器」装载至JVM中的
  • 为了防止内存中存在多份同样的字节码,使用了双亲委派机制(它不会自己去尝试加载类,而是把请求委托给父加载器去完成,依次向上)
  • JDK中的本地方法类一般由根加载器(Bootstrp loader)装载JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader)实现装载入而程序中的类文件则由系统加载器(AppClassLoader)实现装载。

java类加载结构图

打破双亲委派机制是什么意思?

  • 很好理解啊,意思就是:只要我加载类的时候,不是从App ClassLoader-》ExtClassLoader->BootStrap ClassLoader这个顺序找,那就算是打破了啊
  • 因为加载classi核心的方法在LoaderClass类的loadClass方法上(双亲委派机制的核心实现)
  • 那只要我自定义个ClassLoader,重写loadClass方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。

那你知道有哪个场景破坏了双亲委派机制吗?

  • tomcat
  • 部署项目时,会把war包放到tomcat的webapp下,这意味着一个tomcat可以运行多个Web应用程序
    • 那假设我现在有两个Web应用程序,它们都有一个类,叫做User,并且它们的类全限定名都一样,比如都是com.yyy.User。但是他们的具体实现是不一样的
    • 那么Tomcat是如何保证它们是不会冲突的呢?
  • 答案就是,那就是tomcat做了Web应用层级的隔离。Tomcat给每个Web应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找

Tomcat还有哪些类加载器吗?

  • 并不是Web应用程序下的所有依赖都需要隔离的,比如Redis,因为如果版本相同,没必要每个Web应用程序都独自加载一份,就可以Web应用程序之间共享

    • 做法也很简单,Tomcat就在WebAppClassLoader.上加了个父类加载器(SharedClassLoader),如果WebAppClassLoader自身没有加载到某个类,那就委托SharedClassLoader去加载。
    • (无非就是把需要应用程序之间需要共享的类放到一个共享目录下,Share ClassLoader读共享目录的类就好了)
  • 为了隔绝Web应用程序与Tomcat本身的类,又有类加载器(CatalinaClassLoader)来装载Tomcat本身的依赖

  • 如果Tomcat本身的依赖和Web应用还需要共享,那么还有类加载器(CommonClassLoader)来装载进而达到共享

  • 各个类加载器的加载目录可以到tomcat的catalina.properties配置文件上查看

    Tomcat的类加载结构图
    ![](https://cdn.jsdelivr.net/gh/swimminghao/picture@main/img/Q0RM1Q_20211229140203.png)

JDBC你不是知道吗,听说它也是破坏了双亲委派模型的,你是怎么理解的?

  • JDBC定义了接口。具体实现类由各个厂商进行实现嘛(比如MySQL)

    • 类加载有个规则:如里一个类由类加载器A加载那么,这个类的依赖类也是由「相同的类加载器」加载。
    • 我们用JDBC的时候,是用DriverManager进而获取Connection,DriverManager在java.sql包下,显然是由BootStrap类加载器进行装载
    • 当我们使用DriverManager.getConnection()时,得到的一定是厂商实现的类.
    • 但因为这些实现类又不在java包中,BootStrap ClassLoaders并不能加载到各个厂商实现的类
  • DriverManager的解决方案就是,在DriverManager切始化的时候,得到「线程上下文加载器」

    • 获取Connection的时候,是使用「线程上下文加载器」去加载Connection的,而这里的线程上下文加载器实际上还是App ClassLoader
    • 所以在获取Connection的时候,还是先找ExtClassLoader和BootStrapClassLoader,只不过这两加载器肯定是加载不到的,最终会由AppClassLoader进行加载
  • 那这种情况,有的人觉得破坏了双亲委派机制,因为本来明明应该是由BootStrapClassLoader进行加载的,结果来了手「线程上下文加载器」,改掉了
    类加载器
  • 有的人觉得没破坏双亲委派机制,只是改成由「线程上下文加载器」进行类载,但还是遵守着:「依次往上找父类加载器进行加载,都找不到时才由自身加载」。认为“原则“上是没变的。

总结

前置知识:JDK中默认类加载器有三个:AppClassLoader、Ext ClassLoader、BootStrap ClassLoader。AppClassLoader的父加载器为Ext ClassLoader、Ext ClassLoader的父加载器为BootStrap ClassLoader。这里的父子关系并不是通过继承实现的,而是组合。

什么是双亲委派机制:加载器在加载过程中,先把类交由父类加载器进行加载,父类加载器没找到才由自身加载。

双亲委派机制目的:为了防止内存中存在多份同样的字节码(安全)

类加载规则:如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。

如何打破双亲委派机制:自定义ClassLoader,重写loadClass方法(只要不依次往上交给父加载器进行加载,就算是打破双亲委派机制)

打破双亲委派机制案例:Tomcat

  1. 为了Web应用程序类之间隔离,为每个应用程序创建WebAppClassLoader类加载器
  2. 为了Web应用程序类之间共享,把ShareClassLoader作为WebAppClassLoader的父类加载器,如果WebAppClassLoader加载器找不到,则尝试用ShareClassLoader进行加载
  3. 为了Tomcat本身与Web应用程序类隔离,用CatalinaClassLoader类加载器进行隔离,CatalinaClassLoader加载Tomcat本身的类
  4. 为了Tomcat与Web应用程序类共享,用CommonClassLoader作为CatalinaClassLoader和ShareClassLoader的父类加载器
  5. ShareClassLoader、CatalinaClassLoader、CommonClassLoader的目录可以在Tomcat的catalina.properties进行配置

线程上下文加载器:由于类加载的规则,很可能导致父加载器加载时依赖子加载器的类,导致无法加载成功(BootStrap ClassLoader无法加载第三方库的类),所以存在「线程上下文加载器」来进行加载。

l

27、【对线面试官】深入浅出Java内存模型

上一次已经问过了为什么要有Java内存模型

  • 答案是:Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果
  • 强调下:Java内存模型它是一种「规范」,Java虚拟机会实现这个规范。

先聊下Java内存模型的抽象结构?

  • Java内存模型定义了:Java线程对内存数据进行交互的规范。
    • 线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。
    • 本地内存是Java内存模型的抽象概念,并不是真实存在的。

  • Java内存模型规定了:线程对变量的所有操作都必须在「本地内存」进行,「不能直接读写主内存」的变量
    • Java内存模型定义了8种操作来完成「变量如何从主内存到本地内存,以及变量如何从本地内存到主内存」
    • 分别是read/load/use/assign/store/write/lock/unlock操作
    • 对变量一个读写操作就涵盖这些操作

happen-before规则

  • 按我的理解下,happen-before实际上也是一套「规则」。Java内存模型定义了这套规则,目的是为了阐述「操作之间」的内存「可见性」

    • 从上次讲述「指令重排」就提到了,在CPU和编译器层面上都有指令重排的问题。
  • 但:在某些重要的场景下,这一组操作都不能进行重排序,「前面一个操作的结果对后续操作必须是可见的」。

    • Java内存模型就提出了happen-before这套规则,规则总共有8条

      • 比如传递性、volatile变量规则、程序顺序规则、监视器锁的规则…
  • 有了happen-before这些规则。我们写的代码只要在这些规则下,前一个操作的结果对后续操作是可见的,是不会发生重排序的。

volatile内存语义

  • volatile是java的一个关键字

  • 特性:可见性和有序性(禁止重排序)

  • java内存模型这个规范,很大程度下就为了解决可见性和有序性的问题。

volatile是怎么做到可见性和有序性的

  • 为了实现volatile有序性和可见性,定义了4种内存屏障的「规范」,

  • 分别是LoadLoad/LoadStore/StroreLoad/StoreStrore

  • 本质上,就是在volatile前后加上了内存屏障,使得编译器和CPU无法进行重排序,致使有序,并且对volatile变量对其他线程可见

  • Hotspot虚拟机实现

    • 在「汇编」层面上实际是通过Lock前缀指令来实现的(lock支持大部分平台,而fence指令是x86平台的)
    • locK指令能保证:禁止CPU和编译器的重排序(保证了有序性)、保证CPU写核
      心的指令可以立即生效且其他核心的缓存数据失效(保证了可见性)。

volatile和MESl协议是啥关系?

  • 没有直接关联
  • Java内存模型关注的是编程语言层面上,它是高维度的抽象。
  • MESI是CPU缓存一致性协议,不同的CPU架构都不一样,可能有的CPU压根就没用MESI协议.
  • 只不过MESI名声大,大家就都拿他来举例子了。
  • MESI可能只是在「特定的场景下」为实现volatile的可见性/有序性而使用到的一部分罢了
  • 为了让Java程序员屏蔽上面这些底层知识,快速地入门使用volatile变量
  • Java内存模型的happen-before规则中就有对volatile变量规则的定义:对一个volatile变量的写操作相对于后续对这个volatile变量的读操作可见
  • 只要变量声明了volatile关键字,写后再读,读必须可见写的值。(可见性、有序性)

总结

为什么存在Java内存模型:Java为了屏蔽硬件和操作系统访问内存的各种差异,提出了「Java内存模型」的规范,保证了Java程序在各种平台下对内存的访问都能得到一致效果

Java内存模型抽象结构:线程之间的「共享变量」存储在「主内存」中,每个线程都有自己私有的「本地内存」,「本地内存」存储了该线程以读/写共享变量的副本。线程对变量的所有操作都必须在「本地内存」进行,而「不能直接读写主内存」的变量

happen-before规则:Java内存模型规定在某些场景下(一共8条),前面一个操作的结果对后续操作必须是可见的。这8条规则成为happen-before规则

volatile:volatile是Java的关键字,修饰的变量是可见性且有序的(不会被重排序)。可见性&&有序性,由Java内存模型定义的「内存屏障」完成,实际HotSpot虚拟机实现Java内存模型规范,汇编底层是通过Lock指令来实现。

l

28、【对线面试官】JVM内存模型

聊聊JVM的内存结构吧?

  • class文件会被类加载器装载至JVM中,并且JVM会负责程序「运行时」的「内存管理」
  • 而JVM的内存结构,往往指的就是JVM定义的「运行时数据区域」
  • 简单来说就分为了5大块:方法区、堆、程序计数器、虚拟机栈、本地方法栈

顺便讲下你这图上每个区域的内容

  • 程序计数器
    • Java是多线程的语言,假设线程数大于CPU数,就很会有「线程切換」现象,切换意昧着「中断」和「恢复」,那自然就需要有一块区域来保存「当前线程的执行信息」
    • 所以,程序计数器就是用于记录各个线程执行的字节码的地址(分支、循环跳转、异常、线程恢复等都依赖于计数器)
  • 虚拟机栈
    • 每个线程在创建的时候都会创建一个虚拟机栈,每次方法调用都会创建一个「栈帧」。每个「栈帧」会包含几块内容:局部变量表、操作数栈、动态连接和返回地址
    • 作用:它保存方法的局部变量、部分变量的计算并参与了方法的调用和返回。

  • 本地方法栈

    • 本地方法栈跟虚拟机栈的功能类似,虚拟机栈用于管理Java函数的调用,而本地方法栈则用于管理本地方法的调用。这里的「本地方法」指的是「非Java方法」,一般本地方法是使用C语言实现的。
  • 方法区

    • 前面提到了运行时数据区这个「分区」是JVM的「规范」,具体的落地实现,不同的虚拟机厂商可能是不一样的
    • 所以「方法区」也只是JVM中规范的一部分
    • Hotspot虚拟机,就会常常提到「永久代」这个词。 Hotspotl虚拟机在「JDK8前」用「永久代」实现了「方法区」,而很多其他厂商的虚拟机其实是没有「永久代」的概念的
    • 在JDK8中,已经用「元空间」来替代了「永久代」作为「方法区」的实现了
    • 方法区主要是用来存放已被虚拟机加载的「类相关信息」:包括类信息、常量池
      • 类信息又包括了类的版本、字段、方法、接口和父类等信息。
      • 常量池又可以分「静态常量池」和「运行时常量池」
        • 静态常量池主要存储的是「字面量」以及「符号引用」等信息,静态常量池也包括了我们说的「字符串常量池」。
        • 「运行时常量池」存储的是「类加载」时生成的「直接引用」等信息
        • 值得注意的是:从「逻辑分区」的角度而言「常量池」是属于「方法区」的
        • 但自从在「JDK7」以后,就已经把「运行时常量池」和「静态常量池」转移到了「堆」内存中进行存储
        • 对于「物理分区」来说「运行时常量池」和「静态常量池』就属于堆
      • 总体来说,就是逻辑分区和物理实际存储的位置,是不一样的
    • 「堆」是线程共享的区域,几乎类的实例和数组分配的内存都来自于它

    • 「堆」被划分为「新生代」和「老年代」,「新生代」又被进一步划分为Eden和 Survivor区,最后 Survivor由From Survivor 和 To Survivor组成

从「JDK8」已经把「方法区」的实现从「永久代」变成「元空间」,有什么区别?

  • 最主要的区别就是:「元空间」存储不在虚拟机中,而是使用本地内存,JVM不会再出现方法区的内存溢出,以往「永久代」经常因为内存不够用导致跑出OOM异常。
  • 按JDK8版本,总结起来其实就相当于:「类信息」是存储在「元空间」的(也有人把「类信息」这块叫做「类信息常量池」)
  • 而「常量池」用JDK7开始,从「物理存储」角度上就在「堆中」,这是没有变化的。

JVM内存结构和Java內存模型有啥区别吧?

  • Java内存模型是跟「并发」相关的,它是为了屏蔽底层细节而提出的规范,希望在上层(Java层面上)在操作内存时在不同的平台上也有相同的效果
  • JVM内存结构(又称为运行时数据区域),它描述着当我们的 class文件加载至虚拟机后,各个分区的「逻辑结构」是如何的,每个分区承担的作用

总结

JVM内存结构组成:JVM内存结构又称为「运行时数据区域」。主要有五部分组成:虚拟机栈、本地方法栈、程序计数器、方法区和堆。其中方法区和堆是线程共享的。虚拟机栈、本地方法栈以及程序计数器是线程隔离的。

l