你问我答04
- 问: 你常用的集合类有哪些呢?
我常用的有ArrayList ,LinkedList ,HashMap,TreeMap,还有HashSet,treeSet,然后支持并发的用过ConcurrentHashMap,CopyOnWriteArrayList,ConcurrentSkipListMap大概就是这些
- 问: 问:你是在什么场景下用的CopyOnWriteArrayList这个集合呢?
当时的情况是这样的,当时我们的监控系统做了一个动态加载的功能。动态加载的这些扩展模块,都会生成一个对应的model 对象,然后放到CopyOnWriteArrayList里面去。这些扩展模块,主要就是接收我们监控系统的一些消息啊,就比如说有的是用来发异常报警的,而有的是用来持久化这些报警信息的。然后在我们的监控系统里面,就会起一个线程去定期检查这些动态模块的目录。嗯,如果发现新的模块的jar包,那就直接加载上来。然后把这个放到CopyOnWriteArrayList 里面。这个变更一般都是比较少的,所以说是比较符合这个CopyOnWriteArrayList的使用场景啊,毕竟它是比较适合那种读多写少的场景吗。
- 问: 那说说CopyOnWriteArrayList为啥适合读多写少的场景了?
这就跟CopyOnWriteArrayList的底层实现有关系了。他的核心思想是有点读写分离的感觉。它里面最核心的呢是一个volatile 关键字修饰的数组。然后在每次修改这个数组的时候,并不是在原数组上直接进行修改的,而是重新复制一个新的数组出来。然后把新加的元素加进去,然后覆盖原来数组的volatile 引用。那这样的话并发创建的迭代器或者是get方法,要么使用原来的数组,要么就使用新的数组嘛。如果是迭代原来数组的话,迭代器已经拿到了原来数组的引用啊,就不会受到新数组的影响,就会给使用方的感觉都是一致的。就是因为每次新加元素都要复制数组这个操作会导致CopyOnWriteArrayList 不适合写多的场景。因为写多了的话,就可能触发GC。
- 问: 你在实际应用中使用过哪些Queue。
嗯,我常用的是LinkedBlockingQueue,我还知道ArrayBlockingQueue、DelayQueue、ConcurrentLinkedQueue 但是这些都没怎么用过。
- 问: LinkedBlockingQueue是你在什么场景下使用的呢?
我基本上都是用做线程池的任务队列,其实也很少单独用LinkedBlockingQueue,
- 问: 有没有深入研究过LinkedBlockingQueue 的底层实现原理呢?
这个我简单了解过,LinkedBlockingQueue它底层是一个单向的链表。然后有两把锁,一把是在队头弹出元素的时候需要加的锁。另一把是在队尾写入元素的时候,需要加的锁。那这样的话,写入和弹出元素可以并发执行。有些特殊的操作,比如说迭代整个Queue,查找Queue中间的元素的时候呢,就需要同时拿到两把锁才行。所以用LinkedBlockingQueue的话,就别去迭代,或者是查找中间元素啊,就在队头还有队尾操作,这样并发性会比较好。
- 问: ArrayBlockingQueue、LinkedBlockingQueue、ConcurrentLinkedQueue 底层的实现有什么区别
ArrayBlockingQueue底层是用数组实现的。然后它上面只有一把锁,然后LinkedBlockingQueue我们前面已经介绍过了,它是一个列表,然后有两把锁可以提高并发度。ConcurrentLinkedQueue 底层也是一个链表结构,它是通过volatile 关键字,还有CAS操作实现的。但是它的这个吞吐量要比LinkedBlockingQueue要高。,但是他因为是无锁的嘛,所以他没办法实现阻塞等待的这个效果。他触发并发的时候都自旋来解决的。
- 问: 可以在使用计算类的时候,肯定会使用到泛型嘛。嗯,那你看这段代码你可能编译通过吗?
这里边这个到的是animal 的子类啊。嗯。呃,应该是不行的,要是能通过编译的话,呃,我可以往这个list 里面添加其他animals 类了啊,那就和他真正能存储的dog 类型冲突了。那考虑一下怎么才能让这条语句编译通过呢?嗯,呃把animal 这个发型呃改成object 或者是删掉都行。呃,后面使用的话呃,对里面的元素进行强制类型转换啊,然后转换成dog 可以了。
- 问: 有没有个人优雅的方式啊嗯。呃,
那就把animal 泛型改成问号extent animal。呃,这就表示里面可以存任何animal 类型的元素。呃,但是这样的话呢就就没有办法继续往这个list 里面加元素了。嗯,然后你这个list 就一直都是空的。
- 问: 那我们说说线程池吧。嗯,你常用的线上是配置是什么样的?
- 一池5个处理线程
ExecutorService threadPool= Executors.newFixedThreadPool(5);
- 一池一线程
ExecutorService threadPool= Executors.newSingleThreadExecutor();- 一池N线程
ExecutorService threadPool = Executors.newCachedThreadPool();
最常用的就是用单线程的线程池了,呃,就是用newSingleThreadExecutor 创建的。呃,还有就是固定线程数的线程池,呃,就是由newFixedThreadPool创建的线程池。嗯,在我负责的那个监控系统里面。啊,经常是需要使用线程池去做一些异步的i o 操作。呃,之所以会使用这两种线程池,是因为这两种线程池的队列都是LinkedBlockingQueue,它是一个长度无限的Queue。核心线程数还有线程数的上限都是固定的。在那个任务堆积的时候呢,就会堆到堆列里面去。要是使用newCachedThreadPool的话,嗯,它的核心线程数是零,然后最大的线程数是无限的。但是它的队列是一个没有空间的队列。如果任务堆积了,就会造成线程不断的创建。就可能会造就可能会导致就可能会导致线程不断的切换。感觉不是很好。所以我觉得CachedThreadPool适合那种执行比较快,然后不会堆的任务。像我这种任务的场景呢,就不是特别合适。你要是任务量特别大的话,
- 问: 嗯,那会不会把newCachedThreadPool和newFixedThreadPool打爆了?
哦,哦一般情况下都会对线程池的队列,还有那个线程个数加监控的。呃,超过阈值的话,我们会进行限流或者是别的操作,呃,保证不会OOM,实际场景是即使我们用一个合理的评估拿到了一个LinkedBlockingQueue 的长度。那我们也没有办法说保证没有异常流量或者是突增流量进来的。你这个时候还是需要做一些防御性的操作。而且很多时候看似合理的评估,其实都是拍脑袋想出来的,这个基本上不太可信。嗯,而且要是用那个固定长度的任务队列,就会走到具体策略上面去啊,然后默认拒绝策略的话,这些任务就会丢了。一般业务是能够接受延迟,没有办法接受丢任务的。再或者就是自己开发拒绝策略呃,保存任务之类的,这个成本也挺高的。
- 问: 除了你说的这三个线程之外。那有没有用过其他的线程池啊?
我还用过ScheduledThreadPool。啊,
- 问: 为什么要选择用ScheduledThreadPool,而不用那个timer 定时器呢?
主要是分几个方面吧,timer 定时器底层是一个单线程来执行定时任务的啊。如果碰到一个非常耗时的任务,呃,就会导致其他任务延迟执行。ScheduledThreadPool 底层它是线程池来执行任务的,然后任务之间呢是不会互相影响的。还有就是timer 线程是不会捕获定时任务抛出来的异常的。要是出现一个抛异常的任务,整个线程就会停了。还有就是timer,它是依赖系统时间来触发任务的。而如果调整了系统时间,就可能导致任务触发时间不符合预期。而ScheduledThreadPool 就没有这个问题了。
- 问: 你有没有了解过WorkStealingPool。嗯,
这个我大概了解过呃,它底层是用ForkJoinPool实现的。然后核心思想是分制,还有任务窃取,大概的意思就是每个工作线程都维护一个自己的双端队列啊,用来存储分配给自己的任务。然后工作线程从自己的队尾获取任务执行。然后工作线程会对任务进行切分。比如说一个大任务啊,切分得到了一二三四五这五个子任务啊,然后工作线程自己就可以执行一这个任务。像这个二三四五这四个任务就会重新放回到这个队首,然后等待处理。在这个之后其他工作线程可能都会从队首窃取任务进行执行。比如说四五任务窃取走了,然后当前的工作线程就只能拿到二三这两个进行处理了。这个时候呢就是多个线程在跑,然后并发度也就高了。任务处理的话自然就会更快一些。任务呢还可以切分好多次,比如四五还能再切分出更多的子任务,嗯,可能会被其他的空闲工作线程窃取,然后并发度还会更高。然后当前工作线程执行完一二三四之后呢,就会调用JOIN等待四五任务完成。要是窃取任务的线程慢了,当前工作线程还会再去其他的队列里面窃取任务,让他尽快的去完成四五这两个任务。这样整个大任务才能完成。嗯,大概是这样的。
- 问: 你有没有实际使用过ForkJoinPool?
嗯。我没有直接用过ForkJoinPool,因为感觉写一个能够分支的任务不太好写。嗯,而且查问题的时候也不太好查。呃,但是我知道java 八里面的parallel stream,它底层使用的是ForkJoinPool。而且所有的parallel stream 底层使用的都是一个公共的ForkJoinPool。所以不能在parallel stream 进行i o 之类的阻塞操作,要不然会阻塞其他的parallel 操作。
- 问: 那我们再聊聊设计模式吧。你了解哪些设计模式呢?
嗯,我知道的有单例模式,模板方法模式,还有策略模式、工厂模式、命令模式,适配器模式,构造者模式。我常用的差不多就是这些了。
- 问: 找个你印象最深的设计模式展开说说吧。
嗯,我印象最深的话,呃,嗯那就是那个模板方法模式了。呃,我们这个监控系统里面,可以通过Http 请求把这个app 的监控信息报上来,也可以通过rpc方式把后端服务报上来。这两种上报方式处理的核心流程都是差不多的。但是这两种上报方式的网络协议,还有这个序列化协议解析流程都不一样。那这个时候就会使用到模板方法模式。还会结合泛型,把核心的那个处理逻辑抽象出来,然后放到副类里面,然后把这个网络处理请求解析这些不一样的地方放到具体的子类里面去实现。这样就可以把不变的逻辑放到副类里面去,然后把可能发生变化的部分呢就放到子类里面去,就很符合那个开放封闭的原则了。
- 问: 那再说说Mysql吧。大概描述一下一条SQL语句执行的过程吧。
Mysql这个过程我不是特别了解,我尝试描述一下吧。首先这条语句会先通过j d b c 发给mysql,mysql 接收到之后呢,就会先检查这条SQL 语句,是不是select 语句。如果是select语句,就会先去看一看缓存里面是不是可以命中这条查询语句啊,如果有的话就直接返回了啊。如果缓存没有命中的话,就开始解析这条select 语句,他会把这条select语句解析成一个语法树,然后对这条语句的关键字啊,还有表名之类的这些内容进行语法检查。然后就会再走优化器。优化器的话就会根据一些策略,数据量,还有那个能不能走索引啊,还有当前SQL的运行情况之类的,去决定该怎么去执行这条SQL 语句。其实就是生成一个执行计划。这个执行计划呢我们可以通过explain 命令去查看。接下来就是真正执行这个查询计划,就是调用底层的存储引擎。比如Innodb啊,然后去查询数据。然后过滤数据,然后再返回给这个客户端。要是执行的是像这种update delete,insert 这类的修改数据的SQL,还会产生redo log,还有bin log。
- 问: 行,可以,我其实想问的是一条SQL语句里面各个部分是按照什么顺序执行的?
这样啊,那那我先想一想。先是根据from ,还有JOIN,确定这一次要查询几张表呃,然后根据on 条件去确定一下这几个表是按照哪个字段进行连接的。然后再根据where条件去过滤记录。之后再根据group 去进行分组啊,然后再根据having 啊去过滤这些分组。嗯,之后再嗯之后再根据select去过滤。我们要查找的是哪些列啊,然后再执行order by 呃,根据查到的这些列进行排序了。呃,大概就是这样的情况。
- 问: 你既然提到了redo log,那展开说说redo log 是用来干什么的吧。
呃,redo log 是WAL日志的一种,呃,它是用来保证数据持久化的。之所以有redo log日志呢,是因为执行增删改这些SQL 的时候,如果直接去修改磁盘的话,就是随机读写啊,这样效率就会比较慢。所以就先去修改缓存,然后再写redo log文件啊,记录这次修改操作,写redo log呢是一个顺序写磁盘的操作,性能会比随机读写要好很多。然后redo log,它是一种物理日志,里面大概是磁盘里面或者是某个文件里面改了某些字节这种格式。在一个事物里面修改数据的SQL都对应了一条prepare 状态的redo log 啊,然后当事务真正提交的时候,就会再写一条common redo log。这样的话呢这个read log 才是真正的提交了。然后在mysql 崩溃恢复的时候,会读这个redo log。然后检查每条redo log是不是有对应的commit。如果没有commit 的话呢,就会认为这个任务没有提交,然后就会把它回滚掉就行。这样的话呢就可以保证我们的数据不会丢。
- 问: mysql 事物隔离级别有哪几种?
应该是有有四种,一种是读未提交,一种是读提交,一种是可重复读,还有是串行化。
- 问: 你在项目里面用的是哪种隔离级别呢?
嗯,我用的是可重复读。
- 问: 为什么要选择可重复读啊?
因为可重复读,可以防患读,还有脏读这些问题,然后并发性也还可以,不像串行那样并发度那么低。
- 问: 那可重复读是怎么解决脏读和幻读的问题的?
嗯,mysql 里面解决幻读主要是靠MVCC和锁两方面来实现的。如果这个事物里面只有select 语句的话,就只需要用到MVCC,在一张表里面呢,除了我们能看到的这些列之外,还有两个隐藏的列。一个是这一行插入时候的版本号,呃,还有就是这行被删除时候的版本号。在这个事务里面第一次查询的时候,他就会把版本号加一。然后作为后面当前事务的快照id,这个id 就决定了我们能够读取到哪些数据。比如说我当前的版本是五,那在一二三四这些版本里面插入的数据我都是能看到的。那如果是在一二三四这些事物里面删除的数据,我这些是看不到的。要是事务里面有修改操作的话,就要去读最新的数据了。因为你不能说其他事务已经把这一行记录都修改了,提交了,你还要对它进行再次修改。这就有问题了。所以说我们要读的是最新的数据,就不能是快照了。然后在那个事务里面,如果有修改操作的话,就需要加锁。具体加什么锁呢?就要看具体表的结构,还有修改语句了。比如说我的update 语句里面,我有条件的i d 是1,那么这个i d 还是唯一索引的话,那加的就是行锁。如果是个范围修改,那就需要加next key。锁住的是修改的这个范围。然后再对这个范围里面的行进行修改。要是有其它的事物并发修改这个范围的行,就阻塞了。嗯,要是一个事物修改了一行啊,然后又有另一个事物要读取原来的老数据。mysql 不能存储多个版本的数据。这个跟undo log 有关系,在修改操作的时候会生成一个undo log 来存储修改前的内容。这个undo log 主要是用来回滚的,也可以辅助来实现MVCC。你说的这个读事物就是去对应的undo log 里面找对应的老版本数据。再就是这个undo log,它也不会在修改事务提交的时候立刻删除,嗯,它会等待所有使用它的事物都提交了。然后由后台线程去清理,大概是这样。
- 问: 我们再来聊聊索引的问题吧。Innodb索引的话分为几类
分成聚簇索引,还有非聚簇索引。
- 问: 你觉得在使用索引的时候有哪些需要注意的点呢?
需要注意最左前缀的原则,索引是个B+树的结构,不符合最左前缀的话就走不了索引。索引字段呢是一定要有区分度的。像性别这种只有男女两个值的字段,就不适合走索引。然后对于高频的查询,能走覆盖索引的就走覆盖索引。因为要是走的不是聚簇索引的话,还需要根据主键再走一遍聚簇索引,然后汇表查询,这样就多走了一次索引查询。要是实在是走不了覆盖索引的话,可以考虑把这个主键做小一点。要是找不到合适的业务字段做主键的话,可以考虑用这个自增字段之类的啊。因为非聚簇索引的叶子节点其实存的呢都是主键。主键太大的话呢。会导致所有非聚簇索引变大。表里面是不能建非常多的索引的,修改索引列数据的时候,呃,可能会导致B+树节点的拆分,还有合并啊,这个时候会降低表的写入性能。这个时候可以结合业务的思考,合并多个索引,然后使用联合索引的时候呢,就要注意一下,索引下推的优化啊,这样可以减少回表查询的数量。
- 问: 我们来看一个实际的问题吧,问: 在一张表里边儿,我现在有一个字符串 varchar类型的。然后我现在需要一个效果。可以通过这个字段的前缀走索引查询,还希望能通过这个字段的后缀走索引查询。Mysql里面的话有什么思路来实现这个需求?
嗯,我可以在这个表里面存一个正确的字段。同时再加一列存这个呃字符串的倒序啊,你比如说我是abcd存一个正序的列啊,然后再存一个dcba,存个倒序的列。两个列上呢都加上索引啊,然后需要正序查询的话,我的where条件里面就用正序的列,然后需要倒序查的话,我就用那个倒序的列。




