高频面试知识点总结,看看你能答对多少
开篇:题目答案总结并非标准,仅供参考,如果有错误或者更好的见解,欢迎留言讨论,往期公众号整理的一些面试题看这里:java面试题内容聚合
事务
1、什么是事务?事务的特性(acid)
什么是事务:事务是程序中一系列严密的操作,所有操作执行必须成功完成,否则在每个操作所做的更改将会被撤销,这也是事务的原子性(要么成功,要么失败)。
事务特性分为四个:原子性(atomicity)、一致性(consistency)、隔离性(isolation)、持续性(durability)简称acid。
1、原子性:事务是数据库的逻辑工作单位,事务中包含的各操作要么都做,要么都不做。
2、一致性:事务执行的结果必须是使数据库从一个一致性状态变到另一个一致性状态。因此当数据库只包含成功事务提交的结果时,就说数据库处于一致性状态。如果数据库系统运行中发生故障,有些事务尚未完成就*中断,这些未完成事务对数据库所做的修改有一部分已写入物理数据库,这时数据库就处于一种不正确的状态,或者说是不一致的状态。
3、隔离性:一个事务的执行不能其它事务干扰。即一个事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰。
4、持久性:也称永久性,指一个事务一旦提交,它对数据库中的数据的改变就应该是永久性的。接下来的其它操作或故障不应该对其执行结果有任何影响。
2、事务的隔离级别有几种,最常用的隔离级别是哪两种?
并发过程中会出现的问题:
-
丢失更新:是不可重复读的特殊情况。如果两个事物都读取同一行,然后两个都进行写操作,并提交,第一个事物所做的改变就会丢失。
-
脏读:一个事务读取到另一个事务未提交的更新数据。
-
幻读也叫虚读:一个事务执行两次查询,第二次结果集包含第一次中没有或某些行已经被删除的数据,造成两次结果不一致,只是另一个事务在这两次查询中间插入或删除了数据造成的。
-
不可重复读:一个事务两次读取同一行的数据,结果得到不同状态的结果,中间正好另一个事务更新了该数据,两次结果相异,不可被信任。
事务的隔离级别有4种:
1、未提交读(read uncommitted)
-
定义:就是一个事务读取到其他事务未提交的数据,是级别最低的隔离机制。
-
缺点:会产生脏读、不可重复读、幻读。
2、提交读(read committed)
-
定义:就是一个事务读取到其他事务提交后的数据。oracle默认隔离级别。
-
缺点:会产生不可重复读、幻读。
3、可重复读(repeatable read)
-
定义:就是一个事务对同一份数据读取到的相同,不在乎其他事务对数据的修改。mysql默认的隔离级别。
-
缺点:会产生幻读。
4、串行化(serializable)
-
定义:事务串行化执行,隔离级别最高,牺牲了系统的并发性。
-
缺点:可以解决并发事务的所有问题。但是效率地下,消耗数据库性能,一般不使用。
缓存
3、分布式缓存的典型应用场景?
-
页面缓存,用来缓存web页面的内容片段,包括html、css 和图片等,多应用于社交网站等。
-
应用对象缓存,缓存系统作为orm框架的二级缓存对外提供服务,目的是减轻数据库的负载压力,加速应用访问。
-
状态缓存,缓存包括session会话状态及应用横向扩展时的状态数据等,这类数据一般是难以恢复的,对可用性要求较高,多应用于高可用集群。
-
并行处理,通常涉及大量中间计算结果需要共享。
-
事件处理,分布式缓存提供了针对事件流的连续查询(continuous query)处理技术,满足实时性需求。
-
极限事务处理,分布式缓存为事务型应用提供高吞吐率、低延时的解决方案,支持高并发事务请求处理,多应用于铁路、金融服务和电信等领域。
数据库
4、mongodb与mysql的区别?
两种数据库的区别:
-
传统的关系型数据库,数据是以表单为媒介进行存储的。
-
相比较mysql,mongodb以一种直观文档的方式来完成数据的存储。
mongodb的鲜明特征:
-
自带girdfs的分布式文件系统,这也为mongodb的部署提供了很大便利。
-
mongodb内自建了对map-reduce运算框架的支持,虽然这种支持从功能上看还算是比较简单的,相当于mysql里groupby功能的扩展版,不过也为数据的统计带来了方便。
-
mongodb在启动后将数据库中得数据以文件映射的方式加载到内存中,如果内存资源相当丰富的话,这将极大的提高数据库的查询速度。
mongodb的优势:
-
mongodb适合那些对数据库具体格式不明确或者数据库数据格式经常变化的需求模型,而且对开发者十分友好。
-
mongodb官方就自带一个分布式文件系统,mongodb官方就自带一个分布式文件系统,可以很方便的部署到服务器机群上。
mongodb的缺陷:
-
事务关系支持薄弱。这也是所有nosql数据库共同的缺陷,不过nosql并不是为了事务关系而设计的,具体应用还是很需求。
-
稳定性有些欠缺
-
方便开发者的同时,对运维人员提出了更高的要求。
mongodb的应用场景:
-
表结构不明确且数据不断变大:mongodb是非结构化文档数据库,扩展字段很容易且不会影响原有数据。内容管理或者博客平台等,例如圈子系统,存储用户评论之类的。
-
更高的写入负载:mongodb侧重高数据写入的性能,而非事务安全,适合业务系统中有大量“低价值”数据的场景。本身存的就是json格式数据。例如做日志系统。
-
数据量很大或者将来会变得很大:mysql单表数据量达到5-10g时会出现明细的性能降级,需要做数据的水平和垂直拆分、库的拆分完成扩展,mongodb内建了sharding、很多数据分片的特性,容易水平扩展,比较好的适应大数据量增长的需求。
-
高可用性:自带高可用,自动主从切换(副本集):
不适用的场景:
-
mongodb不支持事务操作,需要用到事务的应用建议不用mongodb。
-
mongodb目前不支持join操作,需要复杂查询的应用也不建议使用mongodb。
-
在带“_id”插入数据的时候,mongodb的插入效率其实并不高。如果想充分利用mongodb性能的话,推荐采取不带“_id”的插入方式,然后对相关字段作索引来查询。
关系型数据库和非关系型数据库的应用场景对比:
关系型数据库适合存储结构化数据,如用户的帐号、地址:
-
这些数据通常需要做结构化查询,比如join,这时候,关系型数据库就要胜出一筹。
-
这些数据的规模、增长的速度通常是可以预期的。
-
事务性、一致性。
nosql适合存储非结构化数据,如文章、评论:
-
这些数据通常用于模糊处理,如全文搜索、机器学习。
-
这些数据是海量的,而且增长的速度是难以预期的。
-
根据数据的特点,nosql数据库通常具有无限(至少接近)伸缩性。
-
按key获取数据效率很高,但是对join或其他结构化查询的支持就比较差。
5、mysql索引相关问题。
1)什么是索引?
-
索引其实是一种数据结构,能够帮助我们快速的检索数据库中的数据。
2)索引具体采用的哪种数据结构呢?
-
常见的mysql主要有两种结构:hash索引和b+ tree索引,通常使用的是innodb引擎,默认的是b+树。
3)innodb内存使用机制?
innodb体系结构如图所示:
innodb关于查询效率有影响的两个比较重要的参数分别是innodb_buffer_pool_size,innodb_read_ahead_threshold:
-
innodb_buffer_pool_size指的是innodb缓冲池的大小,该参数的大小可通过命令指定innodb_buffer_pool_size 20g。缓冲池使用改进的lru算法进行管理,维护一个lru列表、一个free列表,free列表存放空闲页,数据库启动时lru列表是空的,当需要从缓冲池分页时,首先从free列表查找空闲页,有则放入lru列表,否则lru执行淘汰,淘汰尾部的页分配给新页。
-
innodb_read_ahead_threshold相对应的是数据预加载机制,innodb_read_ahead_threshold 30表示的是如果一个extent中的被顺序读取的page超过或者等于该参数变量的,innodb将会异步的将下一个extent读取到buffer pool中,比如该参数的值为30,那么当该extent中有30个pages被sequentially的读取,则会触发innodb linear预读,将下一个extent读到内存中;在没有该变量之前,当访问到extent的最后一个page的时候,innodb会决定是否将下一个extent放入到buffer pool中;可以在mysql服务端通过show innodb status中的pages read ahead和evicted without access两个值来观察预读的情况:innodb_buffer_pool_read_ahead:表示通过预读请求到buffer pool的pages;innodb_buffer_pool_read_ahead_evicted:表示由于请求到buffer pool中没有被访问,而驱逐出内存的页数。
可以看出来,mysql的缓冲池机制是能充分利用内存且有预加载机制,在某些条件下目标数据完全在内存中,也能够具备非常好的查询性能。
4)b+ tree索引和hash索引区别?
-
哈希索引适合等值查询,但是无法进行范围查询。
-
哈希索引没办法利用索引完成排序。
-
哈希索引不支持多列联合索引的最左匹配规则。
-
如果有大量重复键值的情况下,哈希索引的效率会很低,因为存在哈希碰撞问题。
5)b+ tree的叶子节点都可以存哪些东西吗?
-
innodb的b+ tree可能存储的是整行数据,也有可能是主键的值。
6)这两者有什么区别吗?
-
在 innodb 里,索引b+ tree的叶子节点存储了整行数据的是主键索引,也被称之为聚簇索引。而索引b+ tree的叶子节点存储了主键的值的是非主键索引,也被称之为非聚簇索引。
7)聚簇索引和非聚簇索引,在查询数据的时候有区别吗?
-
聚簇索引查询会更快,因为主键索引树的叶子节点直接就是我们要查询的整行数据了。而非主键索引的叶子节点是主键的值,查到主键的值以后,还需要再通过主键的值再进行一次查询。
8)主键索引查询只会查一次,而非主键索引需要回表查询多次(这个过程叫做回表)。是所有情况都是这样的吗?非主键索引一定会查询多次吗?
覆盖索引(covering index)指一个查询语句的执行只用从索引中就能够取得,不必从数据表中读取。也可以称之为实现了索引覆盖。当一条查询语句符合覆盖索引条件时,mysql只需要通过索引就可以返回查询所需要的数据,这样避免了查到索引后再返回表操作,减少i/o提高效率。
如,表covering_index_sample中有一个普通索引 idx_key1_key2(key1,key2)。当我们通过sql语句:select key2 from covering_index_sample where key1 = 'keytest';的时候,就可以通过覆盖索引查询,无需回表。
9)在创建索引的时候都会考虑哪些因素呢?
一般对于查询概率比较高,经常作为where条件的字段设置索引。
10)在创建联合索引的时候,需要做联合索引多个字段之间顺序,这是如何选择的呢?
在创建多列索引时,我们根据业务需求,where子句中使用最频繁的一列放在最左边,因为mysql索引查询会遵循最左前缀匹配的原则,即最左优先,在检索数据时从联合索引的最左边开始匹配。
所以当我们创建一个联合索引的时候,如(key1,key2,key3),相当于创建了(key1)、(key1,key2)和(key1,key2,key3)三个索引,这就是最左匹配原则。
11)你知道在mysql 5.6中,对索引做了哪些优化吗?
-
索引条件下推:“索引条件下推”,称为 index condition pushdown (icp),这是mysql提供的用某一个索引对一个特定的表从表中获取元组”,注意我们这里特意强调了“一个”,这是因为这样的索引优化不是用于多表连接而是用于单表扫描,确切地说,是单表利用索引进行扫描以获取数据的一种方式。
-
例如有索引(key1,key2),sql语句中
where key1 = 'xxx' and key2 like '%xxx%'
: -
如果没有使用索引下推技术,mysql会通过key1 = 'xxx'从存储引擎返回对应的数据至mysql服务端,服务端再基于key2 like 判断是否符合条件。
-
如果使用了索引下推技术,mysql首先返回key1='xxx'的索引,再根据key2 like 判断索引是否符合条件,如果符合则通过索引定位数据,如果不符合则直接reject掉。有了索引下推优化,可以在有like条件查询的情况下,减少回表次数。
12)如何知道索引是否生效?
explain显示了mysql如何使用索引来处理select语句以及连接表。可以帮助选择更好的索引和写出更优化的查询语句。使用方法,在select语句前加上explain就可以了。
13)那什么情况下会发生明明创建了索引,但是执行的时候并没有通过索引呢?
在一条单表查询语句真正执行之前,mysql的查询优化器会找出执行该语句所有可能使用的方案,对比之后找出成本最低的方案。这个成本最低的方案就是所谓的执行计划。优化过程大致如下:
-
根据搜索条件,找出所有可能使用的索引。
-
计算全表扫描的代价。
-
计算使用不同索引执行查询的代价。
-
对比各种执行方案的代价,找出成本最低的那一个。
14)为什么索引结构默认使用b+tree,而不是hash,二叉树,红黑树?
-
b+tree是一种多路平衡查询树,节点是天然有序的,非叶子节点包含多个元素,不保存数据,只用来索引,叶子节点包含完整数据和带有指向下一个节点的指针,形成一个有序链表,有助于范围和顺序查找。因为非叶子节点不保存数据,所以同样大小的磁盘页可以容纳更多的元素,同样能数据量的情况下,b+tree相比b-tree高度更低,因此查询时io会更少。
-
b-tree不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少(有些资料也称为扇出),指针少的情况下要保存大量数据,只能增加树的高度,导致io操作变多,查询性能变低;
-
hash索引底层是基于哈希表,就是以key-value存储数据的结构,多个数据在存储关系上是没有任何顺序关系的。只适合等值查询,不适合范围查询,而且也无法利用索引完成排序,不支持联合索引的最左匹配原则,如果有大量重复键值的情况下,哈希索引效率会很低,因为存在哈希碰撞。
-
二叉树:树的高度不均匀,不能自平衡,查找效率跟数据有关(树的高度),并且io代价高。
-
红黑树:树的高度随着数据量增加而增加,io代价高。
6、如何优化mysql?
mysql优化大致可以分为三部分:索引的优化、sql语句优化和表的优化
索引优化可以遵循以下几个原则:
-
联合索引最左前缀匹配原则
-
尽量把字段长度小的列放在联合索引的最左侧(因为字段越小,一页存储的数据量越大,io性能也就越好)
-
order by 有多个列排序的,应该建立联合索引
-
对于频繁的查询优先考虑使用覆盖索引
-
前导模糊查询不会使用索引,比如说like '%aaa%'这种
-
负向条件不会使用索引,如!=,<>,not like,not in,not exists
-
索引应该建立在区分度比较高的字段上 一般区分度在80%以上的时候就可以建立索引,区分度可以使用 count(distinct(列名))/count(*)
-
对于where子句中经常使用的列,最好设置索引
sql语句优化,可以通过explain查看sql的执行计划,优化语句原则可以有:
-
在where和order by涉及的列上建立合适的索引,避免全表扫描
-
任何查询都不要使用select * ,而是用具体的字段列表代替
-
多表连接时,尽量小表驱动大表,即小表join大表
-
用exists代替in
-
尽量避免在where字句中对字段进行函数操作
数据库表优化
-
表字段尽可能用not null
-
字段长度固定表查询会更快
-
将数据库大表按照时间或者一些标志拆分成小表
-
水平拆分:将记录散列到不同的表中,每次从分表查询
-
垂直拆分:将表中的大字段单独拆分到另一张表,形成一对一的关系
7、为什么任何查询都不要使用select *?
-
多出一些不用的列,这些列可能正好不在索引的范围之内(索引的好处不多说)select * 杜绝了索引覆盖的可能性,而索引覆盖又是速度极快,效率极高,业界极为推荐的查询方式。(索引覆盖)
-
数据库需要知道 * 等于什么 = 查数据字典会增大开销(记录数据库和应用程序元数据的目录)。
-
不需要的字段会增加数据传输的时间,即使 mysql 服务器和客户端是在同一台机器上,使用的协议还是 tcp,通信也是需要额外的时间。
-
大字段,例如很长的 varchar,blob,text。准确来说,长度超过 728 字节的时候,会把超出的数据放到另外一个地方,因此读取这条记录会增加一次 io 操作。(mysql innodb)
-
影响数据库自动重写优化sql(类似 java 中编译 class 时的编译器自动优化) 。(oracle)
-
select * 数据库需要解析更多的 对象,字段,权限,属性相关,在 sql 语句复杂,硬解析较多的情况下,会对数据库造成沉重的负担。
-
额外的 io,内存和 cpu 的消耗,因为多取了不必要的列。
-
用 select * 需谨慎,因为一旦列的个数或顺序更改,就有可能程序执行失败。
多线程
java实现多线程有几种方式?
有三种方式:
-
继承thread类,并重写run方法。
-
实现runnable接口,并重写run方法。
-
实现callable接口,并重写run方法,并使用futuretask包装器。
线程的生命周期
1、新建状态(new):新创建了一个线程对象。
2、就绪状态(runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取cpu的使用权。
3、运行状态(running):就绪状态的线程获取了cpu,执行程序代码。
4、阻塞状态(blocked):阻塞状态是线程因为某种原因放弃cpu使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
-
等待阻塞:运行的线程执行wait()方法,jvm会把该线程放入等待池中。(wait会释放持有的锁)
-
同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则jvm会把该线程放入锁池中。
-
其他阻塞:运行的线程执行sleep()或join()方法,或者发出了i/o请求时,jvm会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者i/o处理完毕时,线程重新转入就绪状态。(注意,sleep是不会释放持有的锁)
5、死亡状态(dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
start()方法和run()方法的区别?
-
start()方法会使得该线程开始执行,java虚拟机会去调用该线程的run()方法。
-
通过调用thread类的 start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行run()方法,这里方法 run()称为线程体,它包含了要执行的这个线程的内容,run方法运行结束,此线程随即终止。
-
run()方法只是类的一个普通方法而已,如果直接调用run方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,这样就没有达到多线程的目的。
runnable接口和callable接口的区别?
-
runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已。
-
callable接口中的call()方法是有返回值的,是一个泛型,和future、futuretask配合可以用来获取异步执行的结果。
-
这其实是很有用的一个特性,因为多线程相比单线程更难、更复杂的一个重要原因就是因为多线程充满着未知性,某条线程是否执行了?某条线程执行了多久?某条线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而callable + future/futuretask却可以获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务,真的是非常有用。
volatile关键字
volatile基本介绍:volatile可以看成是synchronized的一种轻量级的实现,但volatile并不能完全代替synchronized,volatile有synchronized可见性的特性,但没有synchronized原子性的特性。可见性即用volatile关键字修饰的成员变量表明该变量不存在工作线程的副本,线程每次直接都从主内存中读取,每次读取的都是最新的值,这也就保证了变量对其他线程的可见性。另外,使用volatile还能确保变量不能被重排序,保证了有序性。
当一个变量定义为volatile之后,它将具备两种特性:
-
①保证此变量对所有线程的可见性:当一条线程修改了这个变量的值,新值对于其他线程可以说是可以立即得知的。java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存,线程的工作内存保存了该线程使用到的变量在主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读取主内存中的变量。
-
②禁止指令重排序优化:
volatile boolean isok = false; //假设以下代码在线程a执行 a.init(); isok=true; //假设以下代码在线程b执行 while(!isok){ sleep(); } b.init();
a线程在初始化的时候,b线程处于睡眠状态,等待a线程完成初始化的时候才能够进行自己的初始化。这里的先后关系依赖于isok这个变量。如果没有volatile修饰isok这个变量,那么isok的赋值就可能出现在a.init()之前(指令重排序,java虚拟机的一种优化措施),此时a没有初始化,而b的初始化就破坏了它们之前形成的那种依赖关系,可能就会出错。
volatile使用场景:
如果正确使用volatile的话,必须依赖下以下种条件:
-
对变量的写操作不依赖当前变量的值。
-
该变量没有包含在其他变量的不变式中。
在以下两种情况下都必须使用volatile:
-
状态的改变。
-
读多写少的情况。
什么是线程安全?
如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。
线程安全的级别:
-
1)不可变:像string、integer、long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用。
-
2)绝对线程安全:不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,java中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,java中也有,比方说copyonwritearraylist、copyonwritearrayset。
-
3)相对线程安全:相对线程安全也就是我们通常意义上所说的线程安全,像vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个vector、有个线程同时在add这个vector,99%的情况下都会出现concurrentmodificationexception,也就是fail-fast机制。
-
4)线程非安全:arraylist、linkedlist、hashmap等都是线程非安全的类。
sleep方法和wait方法有什么区别?
-
原理不同:sleep()方法是thread类的静态方法,是线程用来控制自身流程的,它会使此线程暂停执行一段时间,而把执行机会让给其他线程,等到计时时间一到,此线程会自动苏醒。而wait()方法是object类的方法,用于线程间的通信,这个方法会使当前拥有该对象锁的进程等待,直到其他线程用调用notify()或notifyall()时才苏醒过来,开发人员也可以给它指定一个时间使其自动醒来。
-
对锁的处理机制不同:由于sleep()方法的主要作用是让线程暂停一段时间,时间一到则自动恢复,不涉及线程间的通信,因此调用sleep()方法并不会释放锁。而wait()方法则不同,当调用wait()方法后,线程会释放掉它所占用的锁,从而使线程所在对象中的其他synchronized数据可被别的线程使用。
-
使用区域不同:wait()方法必须放在同步控制方法或者同步语句块中使用,而sleep方法则可以放在任何地方使用。
-
sleep()方法必须捕获异常,而wait()、notify()、notifyall()不需要捕获异常。在sleep的过程中,有可能被其他对象调用它的interrupt(),产生interruptedexception异常。
-
由于sleep不会释放锁标志,容易导致死锁问题的发生,一般情况下,不推荐使用sleep()方法,而推荐使用wait()方法。
写一个会导致死锁的程序。
public class mythread{ private static object lock1 = new object(); private static object lock2 = new object(); public static void main(string[] args) { new thread(()->{ synchronized (lock1){ system.out.println("thread1 get lock1"); try { thread.sleep(1000); } catch (interruptedexception e) { e.printstacktrace(); } synchronized (lock2){ system.out.println("thread1 get lock2"); } system.out.println("thread1 end"); } }).start(); new thread(()->{ synchronized (lock2){ system.out.println("thread2 get lock2"); try { thread.sleep(1000); } catch (interruptedexception e) { e.printstacktrace(); } synchronized (lock1){ system.out.println("thread2 get lock1"); } system.out.println("thread2 end"); } }).start(); } }
类加载过程
1、类加载过程:加载->链接(验证+准备+解析)->初始化(使用前的准备)->使用->卸载
具体过程如下:
1)加载:首先通过一个类的全限定名来获取此类的二进制字节流;其次将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;最后在java堆中生成一个代表这个类的class对象,作为方法区这些数据的访问入口。总的来说就是查找并加载类的二进制数据。
2)链接:
验证:确保被加载类的正确性。
准备:为类的静态变量分配内存,并将其初始化为默认值。
解析:把类中的符号引用转换为直接引用。
-
符号引用即用字符串符号的形式来表示引用,其实被引用的类、方法或者变量还没有被加载到内存中。
-
直接引用则是有具体引用地址的指针,被引用的类、方法或者变量已经被加载到内存中。
直接引用可以是:
-
直接指向目标的指针。(个人理解为:指向对象,类变量和类方法的指针)
-
相对偏移量。(指向实例的变量,方法的指针)
-
一个间接定位到对象的句柄。
为什么要使用符号引用?
符号引用要转换成直接引用才有效,这也说明直接引用的效率要比符号引用高。那为什么要用符号引用呢?这是因为类加载之前,javac会将源代码编译成.class文件,这个时候javac是不知道被编译的类中所引用的类、方法或者变量他们的引用地址在哪里,所以只能用符号引用来表示,当然,符号引用是要遵循java虚拟机规范的。
还有一种情况需要用符号引用,就例如前文举得变量的符号引用的例子,是为了逻辑清晰和代码的可读性。
3)为类的静态变量赋予正确的初始值。
2、类的初始化
1)类什么时候才被初始化:
-
创建类的实例,也就是new一个对象。
-
访问某个类或接口的静态变量,或者对该静态变量赋值。
-
调用类的静态方法。
-
反射(class.forname(“com.lyj.load”))。
-
初始化一个类的子类(会首先初始化子类的父类)。
-
jvm启动时标明的启动类,即文件名和类名相同的那个类。
2)类的初始化顺序
-
如果这个类还没有被加载和链接,那先进行加载和链接
-
假如这个类存在直接父类,并且这个类还没有被初始化(注意:在一个类加载器中,类只能初始化一次),那就初始化直接的父类(不适用于接口)
-
加入类中存在初始化语句(如static变量和static块),那就依次执行这些初始化语句。
-
总的来说,初始化顺序依次是:
(静态变量、静态初始化块)–>(变量、初始化块)–> 构造器;
如果有父类,则顺序是:父类的静态变量 –> 父类的静态代码块 –> 子类的静态变量 –> 子类的静态代码块 –> 父类的非静态变量 –> 父类的非静态代码块 –> 父类的构造方法 –> 子类的非静态变量 –> 子类的非静态代码块 –> 子类的构造方法。
3、类的加载
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个这个类的java.lang.class对象,用来封装类在方法区类的对象。如:
类的加载的最终产品是位于堆区中的class对象。class对象封装了类在方法区内的数据结构,并且向java程序员提供了访问方法区内的数据结构的接口。加载类的方式有以下几种:
-
从本地系统直接加载。
-
通过网络下载.class文件。
-
从zip,jar等归档文件中加载.class文件。
-
从专有数据库中提取.class文件。
-
将java源文件动态编译为.class文件(服务器)。
4、加载器
jvm的类加载是通过classloader及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:
加载器介绍:
1)bootstrapclassloader(启动类加载器):
负责加载java_home中jre/lib/rt.jar里所有的class,加载system.getproperty(“sun.boot.class.path”)所指定的路径或jar。
2)extensionclassloader(标准扩展类加载器):
负责加载java平台中扩展功能的一些jar包,包括javahome中jre/lib/rt.jar里所有的class,加载system.getproperty(“sun.boot.class.path”)所指定的路径或jar。2)extensionclassloader(标准扩展类加载器):负责加载java平台中扩展功能的一些jar包,包括java_home中jre/lib/*.jar或-djava.ext.dirs指定目录下的jar包。载system.getproperty(“java.ext.dirs”)所指定的路径或jar。
3)appclassloader(系统类加载器):
负责加载classpath中指定的jar包及目录中class。
4)customclassloader(自定义加载器):
属于应用程序根据自身需要自定义的classloader,如tomcat、jboss都会根据j2ee规范自行实现。
类加载器的顺序
-
加载过程中会先检查类是否被已加载,检查顺序是自底向上,从custom classloader到bootstrap classloader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有classloader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。
-
在加载类时,每个类加载器会将加载任务上交给其父,如果其父找不到,再由自己去加载。
-
bootstrap loader(启动类加载器)是最*的类加载器了,其父加载器为null。
5、类加载器之双亲委派模型
-
所谓的双亲委派模型指除了启动类加载器以外,其余的加载器都有自己的父类加载器,而在工作的时候,如果一个类加载器收到加载请求,他不会马上加载类,而是将这个请求向上传递给他的父加载器,看父加载器能不能加载这个类,加载的原则就是优先父加载器加载,如果父加载器加载不了,自己才能加载。
-
因为有了双亲委派模型的存在,类似object类重复多次的问题就不会存在了,因为经过层层传递,加载请求最终都会被bootstrap classloader所响应。加载的object对象也会只有一个。并且面对同一jvm进程多版本共存的问题,只要自定义一个不向上传递加载请求的加载器就好啦。
垃圾回收机制
java内存区域划分
我们先来看看java的内存区域划分情况,如下图所示:
私有内存区的区域名和相应的特性如下表所示:
虚拟机栈中的局部变量表里面存放了三个信息:
-
各种基本数据类型(boolean、byte、char、short、int、float、long、double)。
-
对象引用(reference)。
-
returnaddress地址。
这个returnaddress和程序计数器有什么区别?前者是指示jvm的指令执行到了哪一行,后者是指你的代码执行到哪一行。
共享内存区(接下来主要讲jdk1.7)的区域名和相应的特性如下表所示:
哪些内存需要回收?
私有内存区伴随着线程的产生而产生,一旦线程中止,私有内存区也会自动消除,因此我们在本文中讨论的内存回收主要是针对共享内存区。
java堆
新生代gc(minor gc):指发生在新生代的垃圾收集动作,因为java对象大都具备朝生夕灭的特性,所以minor gc非常频繁,一般回收速度也比较快。
老年代gc(major gc/full gc):指发生在老年代的gc,出现了major gc,经常会伴随至少一次的minor gc (但非绝对,在parallel scavenge收集器的收集策略里就有直接进行major gc的策略选择过程)。major gc的速度一般会比minor gc慢10倍以上。
新生代:刚刚新建的对象在eden中,经历一次minor gc, eden中的存活对象就被移动到第一块survivor space s0,eden被清空;等eden区再满了,就再触发一次minor gc, eden和s0中的存活对象会被复制送入第二块survivor space s1。s0和eden被清空,然后下一轮s0与s1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就被送到老年代中。
为什么新生代内存需要有两个sruvivor区:
先不去想为什么有两个survivor区,第一个问题是,设置survivor区的意义在哪里?
如果没有survivor,eden区每进行一次minor gc,存活的对象就会被送到老年代。老年代很快被填满,触发major gc(因为major gc一般伴随着minor gc,也可以看做触发了full gc)。老年代的内存空间远大于新生代,进行一次full gc消耗的时间比minor gc长得多。你也许会问,执行时间长有什么坏处?频发的full gc消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。那我们来想想在没有survivor的情况下,有没有什么解决办法,可以避免上述情况:
显而易见,没有survivor的话,上述两种解决方案都不能从根本上解决问题。我们可以得到第一条结论:survivor的存在意义,就是减少被送到老年代的对象,进而减少full gc的发生,survivor的预筛选保证,只有经历16次minor gc还能在新生代中存活的对象,才会被送到老年代。
设置两个survivor区最大的好处就是解决了碎片化,下面我们来分析一下。为什么一个survivor区不行?
第一部分中,我们知道了必须设置survivor区。假设现在只有一个survivor区,我们来模拟一下流程:
刚刚新建的对象在eden中,一旦eden满了,触发一次minor gc,eden中的存活对象就会被移动到survivor区。这样继续循环下去,下一次eden满了的时候,问题来了,此时进行minor gc,eden和survivor各有一些存活对象,如果此时把eden区的存活对象硬放到survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
那么,顺理成章的,应该建立两块survivor区,刚刚新建的对象在eden中,经历一次minor gc,eden中的存活对象就会被移动到第一块survivor space s0,eden被清空;等eden区再满了,就再触发一次minor gc,eden和s0中的存活对象又会被复制送入第二块survivor space s1(这个过程非常重要,因为这种复制算法保证了s1中来自s0和eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。s0和eden被清空,然后下一轮s0与s1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。
参考文章:https://blog.csdn.net/antony9118/article/details/51425581
老年代:如果某个对象经历了几次垃圾回收之后还存活,就会被存放到老年代中。老年代的空间一般比新生代大。
这个流程如下图所示:
什么时候回收?
java并没有给我们提供明确的代码来标注一块内存并将其回收。或许你会说,我们可以将相关对象设为null或者用system.gc()。然而,后者将会严重影响代码的性能,因为每一次显示调用system.gc()都会停止所有响应,去检查内存中是否有可回收的对象,这会对程序的正常运行造成极大威胁。
另外,调用该方法并不能保障jvm立即进行垃圾回收,仅仅是通知jvm要进行垃圾回收了,具体回收与否完全由jvm决定。
生存还是死亡
可达性算法:这个算法的基本思路是通过一系列的称为“gc roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(reference chain),当一个对象到gc roots没有任何引用链相连时,则证明此对象是不可用的。
二次标记:在可达性分析算法中被判断是对象不可达时不一定会被垃圾回收机制回收,因为要真正宣告一个对象的死亡,必须经历两次标记的过程。
如果发现对象不可达时,将会进行第一次标记,此时如果该对象调用了finalize()方法,那么这个对象会被放置在一个叫f-queue的队列之中,如果在此队列中该对象没有成功拯救自己(拯救自己的方法是该对象有没有被重新引用),
那么gc就会对f-queue队列中的对象进行小规模的第二次标记,一旦被第二次标记的对象,将会被移除队列并等待被gc回收,所以finalize()方法是对象逃脱死亡命运的最后一次机会。
在java语言中,可作为gc roots的对象包括下面几种:
-
虚拟机栈(栈帧中的本地变量表)中引用的对象。
-
方法区中静态属性引用的对象。
-
方法区中常量引用的对象。
-
本地方法栈中jni(即一般说的native方法)引用的对象。
gc的算法
引用计数法(reference counting):
给对象添加一个引用计数器,每过一个引用计数器值就+1,少一个引用就-1。当它的引用变为0时,该对象就不能再被使用。它的实现简单,但是不能解决互相循环引用的问题。
优点:
-
及时回收无效内存,实时性高。
-
垃圾回收过程中无需挂起。
-
没有全局扫描,性能高。
缺点:
-
对象创建时需要更新引用计数器,耗费一部分时间。
-
浪费cpu资源,计数器统计需要实时进行。
-
无法解决循环引用问题,即使对象无效仍不会被回收。
标记-清除(mark-sweep)算法:
分为两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象(后续的垃圾回收算法都是基于此算法进行改进的)。
缺点:效率问题,标记和清除两个过程的效率都不高;空间问题,会产生很多碎片。
复制算法:
将可用内存按容量划分为大小相等的两块,每次只用其中一块。当这一块用完了,就将还存活的对象复制到另外一块上面,然后把原始空间全部回收。高效、简单。
缺点:将内存缩小为原来的一半。
标记-整理(mark-compat)算法
标记过程与标记-清除算法过程一样,但后面不是简单的清除,而是让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
分代收集(generational collection)算法
新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
老年代中,其存活率较高、没有额外空间对它进行分配担保,就应该使用“标记-整理”或“标记-清除”算法进行回收。
增量回收gc和并行回收gc这里就不做具体介绍了,有兴趣的朋友可以自行了解一下。
垃圾收集器
serial收集器:单线程收集器,表示在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。"stop the world"。
parnew收集器:实际就是serial收集器的多线程版本。
-
并发(parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
-
并行(concurrent):指用户线程与垃圾收集线程同时执行,用户程序在继续运行,而垃圾收集程序运行于另一个cpu上。
parallel scavenge收集器:该收集器比较关注吞吐量(throughout)(cpu用于用户代码的时间与cpu总消耗时间的比值),保证吞吐量在一个可控的范围内。
cms(concurrent mark sweep)收集器:cms收集器是一种以获取最短回收停顿时间为目标的垃圾收集器,是基于“标记——清除”算法实现的。
其回收过程主要分为四个步骤:
-
初始标记:标记一下gc roots能直接关联到的对象,速度很快。
-
并发标记:进行gc roots tracing的过程,也就是标记不可达的对象,相对耗时。
-
重新标记:修正并发标记期间因用户程序继续运作导致的标记变动,速度比较快。
-
并发清除:对标记的对象进行统一回收处理,比较耗时。
由于初始标记和重新标记速度比较快,其它工作线程停顿的时间几乎可以忽略不计,所以cms的内存回收过程是与用户线程一起并发执行的。初始标记和重新标记两个步骤需要stop the world;并发标记和并发清除两个步骤可与用户线程并发执行。“stop the world”意思是垃圾收集器在进行垃圾回收时,会暂停其它所有工作线程,直到垃圾收集结束为止。
cms的缺点:
-
对cpu资源非常敏感;也就是说当cms开启垃圾收集线程进行垃圾回收时,会占用部分用户线程,如果在cpu资源紧张的情况下,会导致用户程序的工作效率下降。
-
无法处理浮动垃圾导致又一次full gc的产生;由于cms并发回收垃圾时用户线程同时也在运行,伴随用户线程的运行自然会有新的垃圾产生,这部分垃圾出现在标记过程之后,cms无法在当次收集过程中进行回收,只能在下一次gc时在进行清除。所以在cms运行期间要确保内存中有足够的预留空间用来存放用户线程的产生的浮动垃圾,不允许像其它收集器一样等到老年代区完全填满了之后再进行收集;那么当内存预留的空间不足时就会产生又一次的full gc来释放内存空间,由于是通过serial old收集器进行老年代的垃圾收集,所以导致停顿的时间变长了(系统有一个阈值来触发cms收集器的启动,这个阈值不允许太高,太高反而导致性能降低)。
-
标记——清除算法会产生内存碎片;如果产生过多的内存碎片时,当系统虚拟机想要再分配大对象时,会找不到一块足够大的连续内存空间进行存储,不得不又一次触发full gc。
g1(garbage first)收集器:g1收集器是一款成熟的商用的垃圾收集器,是基于“标记——整理”算法实现的。
其回收过程主要分为四个步骤:
-
初始标记:标记一下gc roots能直接关联到的对象,速度很快。
-
并发标记:进行gc roots tracing的过程,也就是标记不可达的对象,相对耗时。
-
最终标记:修正并发标记期间因用户程序继续运作导致的标记变动,速度比较快。
-
筛选回收:首先对各个region的回收价值和成本进行排序,根据用户所期望的gc停顿时间来制定回收计划。
g1收集器的特点:
-
并发与并行:机型垃圾收集时可以与用户线程并发运行。
-
分代收集:能根据对象的存活时间采取不同的收集算法进行垃圾回收。
-
不会产生内存碎片:基于标记——整理算法和复制算法保证不会产生内存空间碎片。
-
可预测的停顿:g1除了追求低停顿时间外,还能建立可预测的停顿时间模型,便于用户的实时监控。
cms收集器与g1收集器的区别:
-
cms采用标记——清除算法会产生空间碎片,g1采用标记——整理算法不会产生空间碎片。
-
g1可以建立可预测的停顿时间模型,而cms则不能。
jdk 1.8 jvm的变化
1、为什么取消方法区
-
它在启动时固定大小,很难进行调优,并且fullgc时会移动类元信息。
-
类及方法的信息等比较难确定大小,因此对永久代的大小指定比较困难。
-
在某些场景下,如果动态加载类过多,容易造成perm区的oom。
-
字符串存在方法区中,容易出现性能问题和内存溢出。
-
永久代gc垃圾回收效率偏低。
2、jdk 1.8里perm区中的所有内容中字符串常量移至堆内存,其他内容如类元信息、字段、静态属性、方法、常量等都移动到元空间内。
3、元空间
元空间(metaspace)不在堆内存上,而是直接占用的本地内存。因此元空间的大小仅受本地内存限制
也可通过参数来设定元空间的大小:
-
-xx:metaspacesize 初始元空间大小
-
-xx:maxmetaspacesize 最大元空间大小
除了上面两个指定大小的选项以外,还有两个与 gc 相关的属性:
-xx:minmetaspacefreeratio,在gc之后,最小的metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。
-xx:maxmetaspacefreeratio,在gc之后,最大的metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。
元空间的特点:
-
每个加载器有专门的存储空间。
-
不会单独回收某个类。
-
元空间里的对象的位置是固定的。
-
如果发现某个加载器不再存货了,会把相关的空间整个回收。
性能优化:
-
减少new对象。每次new对象之后,都要开辟新的内存空间。这些对象不被引用之后,还要回收掉。因此,如果最大限度地合理重用对象,或者使用基本数据类型替代对象,都有助于节省内存。
-
多使用局部变量,减少使用静态变量。局部变量被创建在栈中,存取速度快。静态变量则是存储在堆内存中。
-
避免使用finalize,该方法会给gc增添很大的负担。
-
如果是单线程,尽量使用非多线程安全的,因为线程安全来自于同步机制,同步机制会降低性能。例如,单线程程序,能使用hashmap,就不要使用hashtabl。同理,尽量减少使用synchronized。
-
用移位符号替代乘除号。比如:a*8应该写作a<<3。
-
对于经常反复使用的对象使用缓存。
-
尽量使用基本类型而不是包装类型,尽量使用一维数组而不是二维数组。
-
尽量使用final修饰符,final表示不可修改,访问效率高。
-
单线程下(或者是针对于局部变量),字符串尽量使用stringbuilder,比stringbuffer要快。
-
尽量使用stringbuffer来连接字符串。这里需要注意的是,stringbuffer的默认缓存容量是16个字符,如果超过16,append方法调用私有的expandcapacity()方法,来保证足够的缓存容量。因此,如果可以预设stringbuffer的容量,避免append再去扩展容量。
java自动装箱拆箱总结
当基本类型包装类与基本类型值进行==运算时,包装类会自动拆箱。即比较的是基本类型值。
具体实现上,是调用了integer.intvalue()方法实现拆箱。
int a = 1; integer b = 1; integer c = new integer(1); system.out.println(a == b); //true system.out.println(a == c); //true system.out.println(c == b); //false integer a = 1; 会调用这个 integer a = integer.valueof(1); integer已经默认创建了数值【-128到127】的integer常量池 integer a = -128; integer b = -128; system.out.println(a == b); //true integer a = 128; integer b = 128; system.out.println(a == b); //false java的数学计算是在内存栈里操作的 c1 + c2 会进行拆箱,比较还是基本类型 int a = 0; integer b1 = 1000; integer c1 = new integer(1000); integer b2 = 0; integer c2 = new integer(0); system.out.println(b1 == b1 + b2); //true system.out.println(c1 == c1 + c2); //true system.out.println(b1 == b1 + a); //true system.out.println(c1 == c1 + a); //true
以上这些,答案总结并非标准,仅供参考,如果有错误或者更好的见解,欢迎留言讨论,往期公众号整理的一些面试题看这里:java面试题内容聚合