基础面试题(二)

93 阅读20分钟

线程池原理

1. 如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;

2. 如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;

3. 如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;

4. 如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。

5. 默认情况下,创建线程池之后,线程池中是没有线程的,需要提交任务之后才会创建线程。

在实际中如果需要线程池创建之后立即创建线程,可以通过以下两个方法办到:

prestartCoreThread():初始化一个核心线程;

prestartAllCoreThreads():初始化所有核心线程

6. workQueue的类型为BlockingQueue,通常可以取下面三种类型:

a) ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小;

b) LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;

c) synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。

7. 还有任务到来就会采取任务拒绝策略,通常有以下四种策略:

a) ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常;

b) ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常;

c) ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程);

d) ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务。当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果

8. ThreadPoolExecutor提供了两个方法,用于线程池的关闭,分别是shutdown()和shutdownNow(),其中:

a) shutdown():不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务

b) shutdownNow():立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务

9. 一般需要根据任务的类型来配置线程池大小:

a) 如果是CPU密集型任务,就需要尽量压榨CPU,参考值可以设为 NCPU+1

b) 如果是IO密集型任务,参考值可以设置为2*NCPU

线程池的几种方式和优缺点

Java通过Executors提供四种线程池,分别为:

1. newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。Executors.newCachedThreadPool(); 缺点:大家一般不用是因为newCachedThreadPool 可以无线的新建线程,容易造成堆外内存溢出,因为它的最大值是在初始化的时候设置为 Integer.MAX_VALUE,一般来说机器都没那么大内存给它不断使用。当然知道可能出问题的点,就可以去重写一个方法限制一下这个最大值

2. newFixedThreadPool Executors.newFixedThreadPool(3);创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。定长线程池的大小最好根据系统资源进行设置。如Runtime.getRuntime().availableProcessors()。可参考PreloadDataCache。其实newFixedThreadPool()在严格上说并不会复用线程,每运行一个Runnable都会通过ThreadFactory创建一个线程

3. newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。Executors.newScheduledThreadPool(5);与Timer 对比:Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务(比如:一个任务出错,以后的任务都无法继续)。

ScheduledThreadPoolExecutor的设计思想是,每一个被调度的任务都会由线程池中一个线程去执行,因此任务是并发执行的,相互之间不会受到干扰。需要注意的是,只有当任务的执行时间到来时,ScheduedExecutor 才会真正启动一个线程,其余时间 ScheduledExecutor 都是在轮询任务的状态。

通过对比可以发现ScheduledExecutorService比Timer更安全,功能更强大,在以后的开发中尽可能使用ScheduledExecutorService(JDK1.5以后)替代Timer

4. newSingleThreadExecutor Executors.newSingleThreadExecutor() 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。现行大多数GUI程序都是单线程的。Android中单线程可用于数据库操作,文件操作,应用批量安装,应用批量删除等不适合并发但可能IO阻塞性及影响UI线程响应的操作。

线程的生命周期

1. 线程的生命周期一共分为五个部分分别是:新建,就绪,运行,阻塞以及死亡。由于cpu需要在多条线程中切换因此线程状态也会在多次运行和阻塞之间切换;

2. 当创建Thread类的一个实例(对象)时,此线程进入新建状态(未被启动);

3. 当调用线程对象的start方法后,线程加入到就绪队列中,等待获取CPU资源,此时线程进入就绪状态;

4. 线程获取到CPU资源,此时线程进入到运行状态;

5. 由于某种原因导致正在运行的线程让出CPU并暂停自己的执行,即进入堵塞状态。如调用sleep方法或者wait方法,可以使当前线程进度堵塞状态,对于sleep方法进入堵塞状态的线程,一段时间后,该线程会进入就绪状态,而对于调用wait方法进入堵塞状态的线程,需要通过调用notify或notifyAll方法使得线程进入就绪状态;

6. 当线程执行完毕或被其它线程杀死,线程就进入死亡状态。

说说线程安全问题

简单来说,线程安全就是: 在多线程环境中,能永远保证程序的正确性。

分三种方式解决:

1. 第一种,修改线程模型。即不在线程之间共享该状态变量。一般这个改动比较大,需要量力而行。

2. 第二种,将对象变为不可变对象。有时候实现不了。

3. 第三种,就比较通用了,在访问状态变量时使用同步。 synchronized和Lock都可以实现同步。简单点说,就是在你修改或访问可变状态时加锁,独占对象,让其他线程进不来。这也算是一种线程隔离的办法。(这种方式也有不少缺点,比如说死锁,性能问题等等)

volatile 实现原理

当修改一个变量的数据时,该数据并不会立即写到内存中,而是写到处理器的缓存行中,这样就导致了其他线程对该数据是不可见的。而当给变量增加了volatile修饰后,当修改变量的数据时,会向CPU发出一个lock指令,该lock指令会将当前处理器缓存行中的数据写到内存中,同时使得其他CPU中缓存了该内存地址的数据无效,这样其他线程在读取该变量的值时,就会直接从内存中去读取。

synchronized和Lock的区别

1. synchronized自动释放锁,而Lock必须手动释放,并且代码中出现异常会导致unlock代码不执行,所以Lock一般在Finally中释放,而synchronized释放锁是由JVM自动执行的;

2. Lock有共享锁的概念,所以可以设置读写锁提高效率,synchronized不能。(两者都可重入);

3. Lock可以让线程在获取锁的过程中响应中断,而synchronized不会,线程会一直等待下去。lock.lockInterruptibly()方法会优先响应中断,而不是像lock一样优先去获取锁;

4. Lock锁的是代码块,synchronized还能锁方法和类;

5. Lock可以知道线程有没有拿到锁,而synchronized不能。

共享锁、排它锁、可重入锁

1. 共享锁又称读锁、S锁,共享锁允许多个线程同时获取一个锁,如CountDownLatch就是一种共享锁;

2. 排它锁又称写锁、X锁,也称为独占锁,一个锁在某一时刻只能被一个线程占用,如ReentrantLock就是一种排它锁(ReentrantReadWriteLock包含读写和写锁功能)

3. 可重入锁指的是支持一个线程对资源的重复加锁。

CAS乐观锁

1. CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换;

2. CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B;

3. 更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

ABA问题与解决方法

1. 在CAS算法中,需要取出内存中某时刻的数据(由用户完成),在下一时刻比较并替换(由CPU完成,该操作是原子的)。这个时间差中,会导致数据的变化。

2. 假设如下事件序列:

线程 1 从内存位置V中取出A。

线程 2 从位置V中取出A。

线程 2 进行了一些操作,将B写入位置V。

线程 2 将A再次写入位置V。

线程 1 进行CAS操作,发现位置V中仍然是A,操作成功。

3. 尽管线程 1 的CAS操作成功,但不代表这个过程没有问题——对于线程 1 ,线程 2 的修改已经丢失。

4. 解决方法:AtomicStampedReference

Mysql索引的注意事项

1. 索引的优点:

a) 大大加快数据的查询速度

b) 使用分组和排序进行数据查询时,可以显著减少查询时分组和排序的时间

c) 创建唯一索引,能够保证数据库表中每一行数据的唯一性

d) 在实现数据的参考完整性方面,可以加速表和表之间的连接

2. 索引的缺点

a) 创建索引和维护索引需要消耗时间,并且随着数据量的增加,时间也会增加

b) 索引需要占据磁盘空间

c) 对数据表中的数据进行增加,修改,删除时,索引也要动态的维护,降低了维护的速度

3. 创建索引的原则

a) 更新频繁的列不应设置索引

b) 数据量小的表不要使用索引(毕竟总共2页的文档,还要目录吗?)

c) 重复数据多的字段不应设为索引(比如性别,只有男和女,一般来说:重复的数据超过百分之15就不该建索引)

d) 首先应该考虑对where 和 order by 涉及的列上建立索引

4. 优化mysql查询语句

a) 不要在where条件语句 '=' 的左边进行函数,运算符或表达式的计算,如 select name from tb_user where age/2=20,因为索引不会生效(引擎会放弃使用索引,进行全表扫描)

b) 不要使用 <>,!=,not in ,因为索引不会生效

c) 避免对字段进行null的判断,因为索引不会生效(可以用一个值代替null,如-999)

d) 使用like模糊查询时,like '%xx%'会导致索引不生效,like 'xx%' 索引能够被使用,所以避免使用第一种

e) 避免使用or,可以用union替代(要想使用or,又让索引生效,or条件中的每个列都必须加上索引)

f) 使用exist代替in(表中数据越多,exist的效率就比in要越大)

g) 数据类型隐形转换,索引不会生效:如 select name from user where phone=13155667788;(phone字段在数据库中为varchar类型,应改成 phone='13155667788')

h) 联合索引必须要按照顺序才会生效:如创建的索引顺序为a,b,where a="xx" and b="xx" 生效,但 b="xx" and a="xx" 则不会生效,补充:a="xx" 没有后面的,索引也会生效

i) 尽量避免使用游标(游标效率低)

j) 不要使用 select *

说说分库与分表设计

1. 垂直分表

垂直分表在日常开发和设计中比较常见,通俗的说法叫做“大表拆小表”,拆分是基于关系型数据库中的“列”(字段)进行的。通常情况,某个表中的字段比较多,可以新建立一张“扩展表”,将不经常使用或者长度较大的字段拆分出去放到“扩展表”中。在字段很多的情况下,拆分开确实更便于开发和维护(笔者曾见过某个遗留系统中,一个大表中包含100多列的)。某种意义上也能避免“跨页”的问题(MySQL、MSSQL底层都是通过“数据页”来存储的,“跨页”问题可能会造成额外的性能开销,拆分字段的操作建议在数据库设计阶段就做好。如果是在发展过程中拆分,则需要改写以前的查询语句,会额外带来一定的成本和风险,建议谨慎。

2. 垂直分库

垂直分库在“微服务”盛行的今天已经非常普及了。基本的思路就是按照业务模块来划分出不同的数据库,而不是像早期一样将所有的数据表都放到同一个数据库中。系统层面的“服务化”拆分操作,能够解决业务系统层面的耦合和性能瓶颈,有利于系统的扩展维护。而数据库层面的拆分,道理也是相通的。与服务的“治理”和“降级”机制类似,我们也能对不同业务类型的数据进行“分级”管理、维护、监控、扩展等。

众所周知,数据库往往最容易成为应用系统的瓶颈,而数据库本身属于“有状态”的,相对于Web和应用服务器来讲,是比较难实现“横向扩展”的。数据库的连接资源比较宝贵且单机处理能力也有限,在高并发场景下,垂直分库一定程度上能够突破IO、连接数及单机硬件资源的瓶颈,是大型分布式系统中优化数据库架构的重要手段。

3. 水平分表

水平分表也称为横向分表,比较容易理解,就是将表中不同的数据行按照一定规律分布到不同的数据库表中(这些表保存在同一个数据库中),这样来降低单表数据量,优化查询性能。最常见的方式就是通过主键或者时间等字段进行Hash和取模后拆分。水平分表,能够降低单表的数据量,一定程度上可以缓解查询性能瓶颈。但本质上这些表还保存在同一个库中,所以库级别还是会有IO瓶颈。所以,一般不建议采用这种做法。

4. 水平分库

水平分库分表与上面讲到的水平分表的思想相同,唯一不同的就是将这些拆分出来的表保存在不同的数据中。这也是很多大型互联网公司所选择的做法。某种意义上来讲,有些系统中使用的“冷热数据分离”(将一些使用较少的历史数据迁移到其他的数据库中。而在业务功能上,通常默认只提供热点数据的查询),也是类似的实践。在高并发和海量数据的场景下,分库分表能够有效缓解单机和单库的性能瓶颈和压力,突破IO、连接数、硬件资源的瓶颈。当然,投入的硬件成本也会更高。同时,这也会带来一些复杂的技术问题和挑战(例如:跨分片的复杂查询,跨分片事务等)。

分库与分表带来的分布式困境与应对之策

随着用户数的不断增加,以及数据量的不断增加,通过分库与分表的方式提高查询性能的同时,带来了一系列分布式困境。

1. 数据迁移与扩容问题

水平分表策略归纳总结为随机分表和连续分表两种情况。连续分表有可能存在数据热点的问题,有些表可能会被频繁地查询从而造成较大压力,热数据的表就成为了整个库的瓶颈,而有些表可能存的是历史数据,很少需要被查询到。连续分表的另外一个好处在于比较容易,不需要考虑迁移旧的数据,只需要添加分表就可以自动扩容。随机分表的数据相对比较均匀,不容易出现热点和并发访问的瓶颈。但是,分表扩展需要迁移旧的数据。

针对于水平分表的设计至关重要,需要评估中短期内业务的增长速度,对当前的数据量进行容量规划,综合成本因素,推算出大概需要多少分片。对于数据迁移的问题,一般做法是通过程序先读出数据,然后按照指定的分表策略再将数据写入到各个分表中。

2. 表关联问题

在单库单表的情况下,联合查询是非常容易的。但是,随着分库与分表的演变,联合查询就遇到跨库关联和跨表关系问题。在设计之初就应该尽量避免联合查询,可以通过程序中进行拼装,或者通过反范式化设计进行规避。

3. 分页与排序问题

一般情况下,列表分页时需要按照指定字段进行排序。在单库单表的情况下,分页和排序也是非常容易的。但是,随着分库与分表的演变,也会遇到跨库排序和跨表排序问题。为了最终结果的准确性,需要在不同的分表中将数据进行排序并返回,并将不同分表返回的结果集进行汇总和再次排序,最后再返回给用户。

4. 分布式事务问题

随着分库与分表的演变,一定会遇到分布式事务问题,那么如何保证数据的一致性就成为一个必须面对的问题。目前,分布式事务并没有很好的解决方案,难以满足数据强一致性,一般情况下,使存储数据尽可能达到用户一致,保证系统经过一段较短的时间的自我恢复和修正,数据最终达到一致。

5. 分布式全局唯一ID

在单库单表的情况下,直接使用数据库自增特性来生成主键ID,这样确实比较简单。在分库分表的环境中,数据分布在不同的分表上,不能再借助数据库自增长特性。需要使用全局唯一 ID,例如 UUID、GUID等。关于如何选择合适的全局唯一 ID,我会在后面的章节中进行介绍。

总结

分库与分表主要用于应对当前互联网常见的两个场景:海量数据和高并发。然而,分库与分表是一把双刃剑,虽然很好的应对海量数据和高并发对数据库的冲击和压力,但是却提高的系统的复杂度和维护成本。

因此建议:需要结合实际需求,不宜过度设计,在项目一开始不采用分库与分表设计,而是随着业务的增长,在无法继续优化的情况下,再考虑分库与分表提高系统的性能。

说说 SQL 优化之道

1. 负向条件查询不能使用索引,可以优化为 in 查询:

select from order where status!=0 and status!=1

2. 前导模糊查询不能使用索引,而非前导模糊查询则可以:

select from order where desc like '%XX'

3. 数据区分度不大的字段不宜使用索引。原因:性别只有男,女,每次过滤掉的数据很少,不宜使用索引。经验上,能过滤80%数据时就可以使用索引:

select from user where sex=1

4. 在属性上进行计算不能命中索引:

select from order where YEAR(date) < = '2017'

即使date上建立了索引,也会全表扫描,可优化为值计算:

select from order where date < = CURDATE()

5. 如果业务大部分是单条查询,使用Hash索引性能更好,例如用户中心。原因:B-Tree 索引的时间复杂度是 O(log(n));Hash 索引的时间复杂度是 O(1):

select from user where uid=?

select from user where login_name=?

6. 允许为 null 的列,查询有潜在大坑。单列索引不存 null 值,复合索引不存全为 null 的值,如果列允许为 null,可能会得到“不符合预期”的结果集:

select from user where name != 'shenjian'

如果 name 允许为 null,索引不存储 null 值,结果集中不会包含这些记录。所以,请使用 not null 约束以及默认值。

7. 使用 ENUM 而不是字符串。ENUM 保存的是 TINYINT,别在枚举中搞一些“中国”“北京”“技术部”这样的字符串,字符串空间又大,效率又低。

8. 如果明确知道只有一条结果返回,limit 1 能够提高效率

select from user where login_name=?

可以优化为:

select from user where login_name=? limit 1

原因:你知道只有一条结果,但数据库并不知道,明确告诉它,让它主动停止游标移动

9. 把计算放到业务层而不是数据库层,除了节省数据的 CPU,还有意想不到的查询缓存优化效果

select from order where date < = CURDATE()

10. 强制类型转换会全表扫描

select from user where phone=13800001234

11. 不要使用 select *,只返回需要的列,能够大大的节省数据传输量,与数据库的内存使用量