数据结构
1.B-树、B+树、B*树
B-树
基本概念 :B-树,即B树。B树的出现是为了弥合不同的存储级别之间的访问速度上的巨大差异,实现高效的 I/O。平衡二叉树的查找效率非常高,并且可以通过降低树的深度来提高查找的效率。但当数据量非常大时,树存储的元素数量是有限的,二叉树结构就会由于树的深度过大而造成磁盘I/O读写过于频繁,进而导致查询效率低下。索引查询的数据主要受限于硬盘的I/O速度,查询I/O次数越少,速度越快。读取树每个节点的元素可以视为一次I/O,树的高度表示最多的I/O次数,在相同数量的总元素个数下,每个节点的元素个数越多,高度越低,查询所需的I/O次数越少。可以看出同样的总元素个数,查询效率和树的高度密切相关,因此B树就很适合解决这类问题
B树是一种自平衡树数据结构,它维护有序数据并允许以对数时间进行搜索,顺序访问,插入和删除。B树是二叉搜索树的一般化,因为节点可以有两个以上的子节点。B树非常适合读取和写入相对较大的数据块(如光盘)的存储系统。它通常用于数据库和文件系统。
定义 :B树是一种平衡的多分树,通常我们说m阶的B树,要满足如下条件
- 每个节点最多只有m个子节点
- 每个非叶子节点(除了根)具有至少有m/2子节点
- 如果根不是叶节点,则根至少有两个子节点
- 具有k个子节点的非叶节点包含k -1个键
- 所有叶子都出现在同一水平,没有任何信息(高度一致)。
阶:B树中一个节点的子节点数目的最大值
特性
- 关键字集合分布在整颗树中
- 任何一个关键字出现且只出现在一个结点中
- 搜索有可能在非叶子结点结束
- 其搜索性能等价于在关键字全集内做一次二分查找
- 自动层次控制(自平衡)
B+树
基本概念:B+树通常用于数据库和操作系统的文件系统中。NTFS, ReiserFS, NSS, XFS, JFS, ReFS 和BFS等文件系统都在使用B+树作为元数据索引。B+ 树的特点是能够保持数据稳定有序,其插入与修改拥有较稳定的对数时间复杂度。B+ 树元素自底向上插入。
定义:B+树是应文件系统需求而出现的一种变型,定义大致与B树相同,m阶的B+树和m阶的B-树的差异在于
- 有n棵子树的结点中含有n个关键字,每个关键字不保存数据,只用来索引,所有数据都保存在叶子节点。
- 所有的叶子结点中包含了全部关键字的信息,及指向含这些关键字记录的指针,且所有叶子结点都有链指针,按照关键字的大小自小而大顺序链接。
- 所有的非终端结点可以看成是索引部分,结点中仅含其子树(根结点)中的最大(或最小)关键字。通常在B+树上有两个头指针,一个指向根结点,一个指向关键字最小的叶子结点。
特性
- 所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的,其搜索性能也等价于在关键字全集做一次二分查找
- 不可能在非叶子结点命中
- 非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层
- 更适合文件索引系统;
B*树
基本概念:B*树是B+树的变体,在B+树的中间节点(非根非叶子结点)增加了指向兄弟的指针
B+树的分裂:当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针;
B*树的分裂:当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针;
所以,B*树分配新结点的概率比B+树要低,空间使用率更高
2.为什么B+树比B-树更适合作数据库索引
B+树的磁盘读写代价更低
B+树的内部结点并没有指向关键字具体信息的指针,因此其内部结点相对B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。举个例子,假设磁盘中的一个盘块容纳16bytes,而一个关键字2bytes,一个关键字具体信息指针2bytes。一棵9阶B树(一个结点最多8个关键字)的内部结点需要2个盘块。而B+树内部结点只需要1个盘块。当需要把内部结点读入内存中的时候,B 树就比B+树多一次盘块查找时间(在磁盘中就是盘片旋转的时间)。
B+树的查询效率更加稳定
由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。
B+树支持区间查询
B树的范围查找用的是中序遍历,而B+树用的是在链表上遍历
3.LSM树
LSM,Log-Structured Merge-Tree,日志结构合并树。它并不属于一个具体的数据结构,更多的是一种数据结构的设计思想。大多NoSQL数据库核心思想都是基于LSM来做的,只是具体的实现不同。
背景:传统关系型数据库使用B树或其变体(如B+树)作为存储结构,能高效进行查找。但保存在磁盘中时会有一个明显的缺陷,那就是逻辑上相离很近但物理却可能相隔很远,这就可能造成大量的磁盘随机读写。整个磁盘IO最耗时的地方在查找时间,所以减少查找时间能大幅提升性能。随机读写比顺序读写慢很多,为了提升IO性能,我们需要一种能将随机操作变为顺序操作的机制,于是便有了LSM树。LSM树能让我们进行顺序写磁盘,从而大幅提升写操作,作为代价的是牺牲了一些读性能。
原理:LSM树会将所有的数据插入、修改、删除等操作保存在内存之中,当此类操作达到一定的数据量后,再批量地写入到磁盘当中。而在写入磁盘时,会和以前的数据做合并。在合并过程中,并不会像B+树一样,在原数据的位置上修改,而是直接插入新的数据,从而避免了随机写。
结构:LSM树的结构是横跨内存和磁盘的,包含memtable、immutable memtable、SSTable等部分。
memtable:顾名思义,memtable是在内存中的数据结构,用以保存最近的一些更新操作,当写数据到memtable中时,会先通过WAL的方式备份到磁盘中,以防数据因为内存掉电而丢失。memtable可以使用跳跃表或者搜索树等数据结构来组织数据以保持数据的有序性。当memtable达到一定的数据量后,memtable会转化成为immutable memtable,同时会创建一个新的memtable来处理新的数据。
预写式日志(Write-ahead logging,缩写 WAL)是关系数据库系统中用于提供原子性和持久性(ACID属性中的两个)的一系列技术。在使用WAL的系统中,所有的修改在提交之前都要先写入log文件中。
immutable memtable:顾名思义,immutable memtable在内存中是不可修改的数据结构,它是将memtable转变为SSTable的一种中间状态。目的是为了在转存过程中不阻塞写操作。写操作可以由新的memtable处理,而不用因为锁住memtable而等待。
SSTable:SSTable(Sorted String Table)即为有序键值对集合,是LSM树组在磁盘中的数据的结构。如果SSTable比较大的时候,还可以根据键的值建立一个索引来加速SSTable的查询。memtable中的数据最终都会被转化为SSTable并保存在磁盘中,后续还会有相应的SSTable日志合并操作,也是LSM树结构的重点。
4.红黑树
二叉树是一个简单的二分查找,但其性能取决于二叉树的层数
- 最好的情况是O(logn),存在于完全二叉树情况下,其访问性能近似于折半查找
- 最差的情况是O(n),比如插入的元素所有节点都没有左(右)子树,这种情况需要将二叉树的全部节点遍历一次。
概述:红黑树本质上是一种二叉查找树,在节点类中添加类一个用来标识颜色的字段,同时具有一定的规则。同时具备这亮点使得红黑树的性能达到理想中的均衡状态(插入、删除、查找的最坏时间负责度为O(logn))。在Java1.8中HashMap使用的就是链表和红黑树,Java集合中的TreeSet和TreeMap,C++ STL中的set、map,以及Linux虚拟内存的管理,都是通过红黑树去实现的。红黑树是复杂的数据结构,但它的操作有着良好的最坏情况运行时间,并且在实践中是高效的:它可以在 O(logn)时间内做查找,插入和删除
特性
- 每个节点不是红色就是黑色的
- 根节点总是黑色的
- 所有的叶节点都是是黑色的(红黑树的叶子节点都是空节点(NIL或者NULL))
- 如果节点是红色的,则它的子节点必须是黑色的(反之不一定)
- 从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度),由此可以确保没有一条路径会比其他路径长出2倍。因而红黑树是相对是接近平衡的二叉树
红黑树的修正:变色、左旋、右旋是红黑树在二叉树上的扩展操作,同时也是基于这三个操作才能遵守红黑树的五个特性。
- 变色仅仅指的是红黑树节点的变色。因为红黑树节点必须是【红】或者【黑】这两中颜色,所以变色只是将当前的节点颜色进行变化,以满足特性
- 通常左旋操作用于将一个向右倾斜的红色链接旋转为向左链接
- 右旋可左旋刚好相反
5.跳跃表
跳跃表(跳表)是一种基于有序链表的拓展,可以看成多个有序链表,它也是Redis是有序集合的底层实现之一。有序链表不像数组那样可以借助二分查找来查询元素,只能通过遍历,而跳跃表解决了这个问题
插入实现
- 插入的新节点和各层索引节点逐一比较,确定原链表的插入位置后把索引插入到原链表(最底层链表)。
- 随机决定新插入的节点是否提升为上一级索引,并以此类推
- 插入时间复杂度为O(logN)
删除实现
- 自上而下查找第一次出现该删除节点的索引,然后逐层查找每一层对应的节点并删除
- 如果删除层只剩一个节点,则要删除整层链表(原链表除外)。
- 删除时间复杂度为O(logN)
查找实现
- 在查找时从上层指针开始查找,找到对应的区间之后再到下一层去查找。
- 查找 时间复杂度为O(logN)
优点
- 插入速度非常快速,因为不需要像红黑树那样进行旋转等操作来维护平衡性
- 更容易实现
- 支持无锁操作
缺点
- 内存占用比红黑树大(每个节点多个指针)
- 由于插入时是随机选择上级索引,缓存友好性不够好
6.B+树如何减少IO次数
B+树作为MySQL的索引,数据的查询通常需要两部:首先将索引页加载到缓存中,然后从缓存中找数据页;接着将数据页数据加载到缓存,然后将数据返回出去,因为索引页开始是放在磁盘的,要用的时候才取出所以要进行I/O,因此索引页数据越多需要的I/O越少
与二叉树、红黑树比较:用二叉树、红黑树作索引结构的话,索引页的存储的数量不够大,树的深度会很深,因此要进行的I/O操作会很多,并且数据页没有按顺序排序,范围查找会比较困难
与B树比较:B+树的索引页中全是索引,而B树的索引页还会存储数据,这样效率不如B+树
7.字典树
字典树,又称单词查找树、Trie树、前缀树。它是一种树形结构的数据结构,因为其搜索快捷的特性常被单词搜索系统使用,基本性质如下:
- 根节点不包含字符,除根节点外的每一个子节点都包含一个字符。
- 从根节点到某一个节点,路径上经过的字符连接起来为该节点对应的字符串。
- 每个节点的所有子节点包含的字符互不相同。
字典树的核心思想是空间换时间,利用字符串的公共前缀来减少无谓的字符串比较以提高查询效率
优点
- 插入和查询的效率很高,都为O(m),其中m是待插入(查询)的字符串的长度
- 字典树中不同的关键字不会产生冲突
- 字典树可以对关键字按字典序排序
缺点
- 当哈希表的哈希冲突很少或没有时,字典树的查找效率会低于哈希搜索
- 字典树的空间占用率较大
应用
- 字符串检索:实现思路是用一个布尔变量表示标志位。在检索时从根节点开始一个一个字符进行比较检索的字符串,如果沿路比较发现不存在当前字符,则表示该字符串在集合中不存在。如果所有字符比较完成且全部相同,则还需判断最后一个节点的标志位(标志位表示该节点能否代表一个关键字,防止到当前节点为止的路径只是原本存储字符串的一部分)
- 文本词频统计 :实现思路用一个整型变量
count
表示计数。在存储字符串时对每一个关键字执行插入操作,若已存在则计数加1,若不存在则在插入后将count
置为1 - 字符串排序:实现思路是遍历一次所有字符串,将其全部插入字典树,树的每个结点的所有儿子会按照字母表排序,然后按先序遍历输出字典树中的所有关键字即可
- 前缀匹配:实现思路是用所有字符串构造一个字典树,然后输出以指定字符串作前缀的路径上的关键字。字典树的前缀匹配功能常用于搜索提示。如输入一个网址时会自动弹出可能的选择
缓存
1.Redis常见数据类型及应用场景
类型 | 简介 | 特性 | 场景 |
---|---|---|---|
String(字符串) | 二进制安全 | 可以包含任何数据,比如jpg图片或者序列化的对象,一个键最大能存储512M | 数字型字符串自增、自减 |
Hash(字典) | 键值对集合,即编程语言中的Map类型 | 适合存储对象,并且可以像数据库中update一个属性一样只修改某一项属性值(Memcached中需要取出整个字符串反序列化成对象修改完再序列化存回去) | 存储、读取、修改用户属性 |
List(列表) | 链表(双向链表) | 增删快,提供了操作某一段元素的API | 1、最新消息排行等功能(比如朋友圈的时间线) 2、消息队列 |
Set(集合) | 哈希表实现,元素不重复 | 添加、删除、查找的复杂度都是O(1) ;为集合提供了求交集、并集、差集等操作 | 1、共同好友 2、利用唯一性,统计访问网站的所有独立ip 3、好友推荐时,根据tag求交集,大于某个阈值就可以推荐 |
Sorted Set(有序集合) | 将Set中的元素增加一个权重参数score,元素按score排列 | 数据插入集合时,已经进行天然排序 | 1、排行榜 2、带权重的消息队列 |
Redis实际应用场景
Redis在很多方面与其他数据库解决方案不同:它使用内存提供主存储支持,而仅使用硬盘做持久性的存储;它的数据模型非常独特,用的是单线程。另一个大区别在于在开发环境中使用Redis的功能,不需要转到Redis。转向Redis当然也是可取的,许多开发者从一开始就把Redis作为首选数据库;但设想如果开发环境已经搭建好,应用已经在上面运行了,那么更换数据库框架显然不那么容易。另外在一些需要大容量数据集的应用,Redis也并不适合,因为它的数据集不会超过系统可用的内存。所以如果应用为大数据应用,且主要模式为读取访问,那么不要使用Redis
Redis可以附加地融入系统,就能解决一些现有数据库处理缓慢的问题。通过Redis实现优化或为应用创建些新功能。下面介绍一些实际应用场景(在这些例子中,Redis都不作为首选数据库
- 显示最新的数据列表 :如果采用SQL语句来显示最新数据,随着数据增多查询无疑越来越慢。这时可以用Redis的List数据类型存储数据,并在获取时将列表裁剪为指定长度,因此Redis只需要保存最新的5000条评论,这样只有当超出了指定条数后Redis才会去数据库查询
- 排行榜相关:另一个很普遍的需求是各种数据库的数据并非存储在内存中,因此在按得分排序以及实时更新这些几乎每秒钟都需要更新的功能上数据库的性能不够理想。 典型的比如那些在线游戏的排行榜,通过Redis可以实现列出前N名的高分选手、列出某用户当前的全球排名等
- 计数:Redis是一个很好的计数器,能通过INCRBY等指令实现数据自增。 用传统数据库添加新的计数器会存在写入敏感的问题。而使用Redis的原子递增,可以加上任意计数,用GETSET重置,或者是让它们过期。
2.Redis是单线程为什么还这么快
- 纯内存操作
- 核心是基于非阻塞的 IO 多路复用机制
- 单线程反而避免了多线程的频繁上下文切换问题
3.Redis 内存淘汰机制
Redis内存淘汰
- 机制:用户存储的一些键被可以被Redis主动地从实例中删除,从而产生读miss
- 目的:为了更好地使用内存,用一定的缓存miss来换取内存的使用效率
- 过程
- 首先配置最大使用内存maxmemory
- 客户端发起了需要申请更多内存的命令(如set)。
- Redis检查内存使用情况,如果已使用的内存大于最大使用内存maxmemory则开始根据用户配置的不同淘汰策略来淘汰内存(淘汰键值对),从而换取一定的内存。
maxmemory为0的时候表示Redis的内存使用没有限制。
Redis内存淘汰策略
- volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
- allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
- allkeys-random:从数据集中任意选择数据淘汰
- noeviction:禁止淘汰数据,当内存使用达到阈值的时候,所有引起申请内存的命令会报错
Redis在不同版本中默认的内存淘汰策略不同,如在2.8.13的版本里,默认是noeviction,在3.2.3版本里默认是volatile-lru
内存淘汰策略适用场景
- allkeys-lru:如果我们的应用对缓存的访问符合幂律分布(也就是存在相对热点数据),或者我们不太清楚我们应用的缓存访问分布状况,我们可以选择allkeys-lru策略。
- allkeys-random:如果我们的应用对于缓存key的访问概率相等,则可以使用这个策略。
- volatile-ttl:这种策略可以手动向Redis提示哪些key更适合被淘汰。
4.缓存穿透、击穿、雪崩、热点数据失效
- 缓存穿透:正常情况下,查询的数据都存在,如果请求一个不存在的数据,也就是缓存和数据库都查不到这个数据,每次都会去数据库查询,这种查询不存在数据的现象我们称为缓存穿透
- 造成问题 :如果每次都拿一个不存在的id去查询数据库,必然会导致的数据库压力增大
- 解决办法:
- 请求参数校验:对于带有明显不存在数据的参数的请求直接过滤,不进行处理
- 缓存空值:在缓存为这些不存在数据的请求存储key值,并设置对应的值为null,后面的请求再查询相同key的时候就不会查询数据库了,并且要为key值设置过期时间
- 布隆过滤器:布隆过滤器类似于一个hbase set 用来判断某个元素(key)是否可能存在于某个集合中,把有数据的key都放到布隆过滤器中,每次查询的时候都先去布隆过滤器判断,如果没有就直接返回null
- 缓存击穿 :在高并发的情况下,大量的请求同时查询同一个key时,此时这个key正好失效了,就会导致同一时间,这些请求都会去查询数据库,这样的现象我们称为缓存击穿
- 造成问题:会造成某一时刻数据库请求量过大
- 解决办法:采用分布式锁,只有拿到锁的第一个线程去请求数据库,获得数据后就将数据写入缓存让缓存重新有效,当然每次拿到锁的时候都要去查询一下缓存有没有
- 缓存雪崩:当某一时刻发生大规模的缓存失效的情况,如缓存服务器宕机了
- 造成问题:所有请求会直接去访问数据库,使数据库压力增大
- 解决办法:
- 采用集群:降低服务宕机的概率
- ehcache本地缓存 + Hystrix限流&降级:ehcache 本地缓存的目的也是考虑在 Redis Cluster 完全不可用的时候,将ehcache作为本地缓存还能够支撑一阵
- 持久化恢复:在缓存雪崩之后要用Redis的持久化文件进行数据恢复
- 热点数据集中失效:在设置缓存时一般会给缓存设置一个失效时间,过了这个时间,缓存就失效了。对于一些热点的数据来说,当缓存失效以后会存在大量的请求过来,然后打到数据库去,从而可能导致数据库崩溃的情况
- 解决办法:
- 设置不同的失效时间
- 采用分布式加锁
- 永不失效,就是采用定时任务对快要失效的缓存进行更新缓存和失效时间
- 解决办法:
5.Redis二进制安全
二进制安全:在传输数据时,保证二进制数据的信息安全,也就是不被篡改、破译等,如果被攻击,也能够及时检测出来。二进制安全包含了密码学的一些东西,比如加解密、签名等。比如将数据11110000加密成10001000,然后传输就是一种二进制安全的做法
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符’\0’,否则最先被程序读入的空字符将被误认为是字符串结尾,这限制了C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
而Redis为了确保其字符串可以适用于各种不同的使用场景,字符串实现的底层SDS(simple dynamid string )的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。这也是将SDS的buf属性称为字节数组的原因——Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据,SDS是通过使用len属性值而不是空字符来判断字符串是否结束。
Redis使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据
6.Redis数据结构底层
Redis的5种常见数据结构有字符串(String)、列表(List)、散列(Hash)、集合(Set)、有序集合(Sorted Set),不过这些是Redis对外暴露的数据结构,用于API的操作,实际组成它们的底层基础数据结构如下
简单动态字符串:字符串(String)的底层是实现就是简单动态字符串(SDS,Simple Dynamic String)实现的,在Redis3.2版本后的属性有,会根据字符串的长度来选择对应的数据结构(字节数组长度不同)
len
:记录当前已使用的字节数(不包括'\0'
),获取SDS长度的复杂度为O(1)alloc
:记录当前字节数组总共分配的字节数量(不包括'\0'
)flags
:标记当前字节数组的属性buf
:字节数组,用于保存字符串,包括结尾空白字符'\0'
链表:列表(List)的底层实现就是链表。C语言并没有实现链表,所以Redis实现了自己的链表数据结构。每个节点
listNode
可以通过prev
和next
指针分布指向前一个节点和后一个节点组成双端链表,同时每个链表还会有一个list
结构为链表提供表头指针head
、表尾指针tail
、以及链表长度计数器len
字典:哈希(Hash)的底层实现之一就是字典。字典又称符号表、关联数组或映射,是一种用于保存键值对的抽象数据结构。字典中的每一个键都是唯一的,可以通过键查找与之关联的值,并对其修改或删除
当一个新的键值对要添加到字典中时,会根据键值对的键计算出哈希值和索引值,根据索引值放到对应的哈希表上,即如果索引值为0,则放到
ht[0]
哈希表上。当有两个或多个的键分配到了哈希表数组上的同一个索引时,就发生了键冲突的问题,哈希表使用链地址法来解决,即使用哈希表节点的next
指针,将同一个索引上的多个节点连接起来。当哈希表的键值对太多或太少,就需要对哈希表进行扩展和收缩,通过rehash
(重新散列)来执行跳跃表:有序集合(Sorted Set)的底层实现之一就是跳跃表。如果有序集合包含的元素比较多,或者元素的成员是比较长的字符串时,Redis会使用跳跃表做有序集合的底层实现,跳表解释见前面笔记
整数集合:集合(Set)的底层实现之一是整数集合。如果一个集合只包含整数值元素,且元素数量不多时,会使用整数集合作为底层实现。组成有
contents
数组:整数集合的每个元素在数组中按值的大小从小到大排序,且不包含重复项length
记录整数集合的元素数量,即contents数组长度encoding
决定contents数组的真正类型,如INTSET_ENC_INT16、INTSET_ENC_INT32
当想要添加一个新元素到整数集合中时,并且新元素的类型比整数集合现有的所有元素的类型都要长,整数集合需要先进行升级(upgrade),才能将新元素添加到整数集合里面。每次想整数集合中添加新元素都有可能会引起升级,每次升级都需要对底层数组已有的所有元素进行类型转换。整数集合的升级策略可以提升整数集合的灵活性,并尽可能的节约内存另外,整数集合不支持降级,一旦升级编码就会一直保持升级后的状态
压缩列表:列表(List)和散列(Hash)的底层实现之一是压缩列表,一个列表只包含少量列表项,并且每个列表项是小整数值或比较短的字符串,会使用压缩列表作为底层实现
7.Redis持久化
Redis有两种持久化的方式:快照(RDB
文件)和追加式文件(AOF
文件)
- RDB持久化方式会在一个特定的间隔保存那个时间点的一个数据快照。
- AOF持久化方式则会记录每一个服务器收到的写操作。在服务启动时,这些记录的操作会逐条执行从而重建出原来的数据。写操作命令记录的格式跟Redis协议一致,以追加的方式进行保存。
- Redis的持久化是可以禁用的,就是说你可以让数据的生命周期只存在于服务器的运行时间里。
- 两种方式的持久化是可以同时存在的,但是当Redis重启时,AOF文件会被优先用于重建数据。
RDB
工作原理:Redis调用fork(),产生一个子进程,子进程把数据写到一个临时的RDB文件,当子进程写完新的RDB文件后,把旧的RDB文件替换掉。
优点
- 适合用于做备份。RDB文件保存了某个时间点的Redis数据,可以设定一个时间点对RDB文件进行归档,这样就能在需要的时候很轻易的把数据恢复到不同的版本。
- 很适合用于容灾备份。单文件很方便就能传输到远程的服务器上。
- 性能很好。需要进行持久化时,主进程会fork一个子进程出来,然后把持久化的工作交给子进程,自己不会有相关的I/O操作。
- 速度快。比起AOF,在数据量比较大的情况下,RDB的启动速度更快。
缺点
- 容易造成数据丢失。假设每5分钟保存一次快照,如果Redis因为某些原因不能正常工作,那么从上次产生快照到Redis出现问题这段时间的数据就会丢失了。
- RDB使用
fork()
产生子进程进行数据的持久化,如果数据比较大的话可能就会花费点时间,造成Redis停止服务几毫秒。如果数据量很大且CPU性能不是很好的时候,停止服务的时间甚至会到1秒。
AOF
- 工作原理:每当Redis接受到会修改数据集的命令时,就会把命令追加到AOF文件里,当你重启Redis时,AOF里的命令会被重新执行一次,重建数据。
- 优点
- 可靠性好。可以制定不同的fsync策略:不进行fsync、每秒fsync一次和每次查询进行fsync。默认是每秒fsync一次。这意味着你最多丢失一秒钟的数据。
- 纯追加方式。就算是遇到突然停电的情况,也不会出现日志的定位或者损坏问题。甚至如果因为某些原因(例如磁盘满了)命令只写了一半到日志文件里,我们也可以用
redis-check-aof
这个工具很简单的进行修复。 - 缩小文件。当AOF文件太大时,Redis会自动在后台进行重写。重写很安全,因为重写是在一个新的文件上进行,同时Redis会继续往旧的文件追加数据。新文件上会写入能重建当前数据集的最小操作命令的集合。当新文件重写完,Redis会把新旧文件进行切换,然后开始把数据写到新文件上。
- 内容为操作命令。AOF把操作命令以简单易懂的格式一条接一条的保存在文件里,很容易导出来用于恢复数据。例如我们不小心用
FLUSHALL
命令把所有数据刷掉了,只要文件没有被重写,我们可以把服务停掉,把最后那条命令删掉,然后重启服务,这样就能把被刷掉的数据恢复回来。
- 缺点
- 在相同的数据集下,AOF文件的大小一般会比RDB文件大。
- 在某些fsync策略下,AOF的速度会比RDB慢。通常fsync设置为每秒一次就能获得比较高的性能,而在禁止fsync的情况下速度可以达到RDB的水平。
- 在过去曾经发现一些很罕见的BUG导致使用AOF重建的数据跟原数据不一致的问题。
8.Redis变慢原因及解决方案
Redis作为内存数据库,拥有非常高的性能。但在使用时会时不时出现变慢(访问延迟很大)的情况,这些情况往往会存在以下原因
使用复杂度高的命令:业务中如果经常使用O(N)以上复杂度的命令,例如
sort
、sunion
、zunionstore
,或者是在执行O(N)命令时操作的数据量比较大,这些情况下Redis处理数据时就会很耗时,可以通过查询Redis慢日志来发现这些复杂命令。解决方案:不使用这些复杂度较高的命令,并且每次不要获取过多数据,尽量只操作少量数据,让Redis可以及时处理并返回存储key值过大:如果慢日志中的命令不是时间复杂度高的命令而是
set
、delete
这类O(1)级别的,那就要考虑key值是否写入过大。Redis在写入数据时需要为新数据分配内存,删除数据时也会释放对应的内存空间。如果key值非常大,Redis在分配内存和释放内存时会比较耗时。解决方案:不建议/避免存入过大key值key值集中过期:使用Redis时可能不会一直变慢,而是一阵一阵有规律的延时,这种情况就需要考虑是否存在大量key集中过期,并且要注意这种访问延迟情况,不会记录在慢日志里(慢日志中只记录真正执行某个命令的耗时),所以需要通过检查业务代码是否存在有集中过期的代码。解决方案:不改变集中过期的方式,但在整个集中期间用不同随机数来分配随机时间,这样Redis在处理过期数据时不会因为集中删除key导致压力过大,阻塞主线程
内存达到上限:将Redis作为纯缓存来使用时,会给其设置一个内存上限,然后开启相应的内存淘汰策略如LRU。当内存达到了上限后,每次写入新数据都需要进行淘汰,从而会造成耗时。解决方案:根据业务场景选择合适的淘汰策略,并且部署Redis集群来分摊压力
fork耗时严重:如果Redis开启了RDB和AOF的持久化功能,则有可能在后台生成RDB和AOF文件时导致Redis的访问延迟增大。这两种方式都需要父进程
fork
出一个子进程进行数据持久化,如果整个Redis服务器的内存占用过大,则在进行持久化时会消耗大量CPU资源。解决方案:从节点上执行备份,且最好放在低峰期执行。如果对于丢失数据不敏感的业务,不建议开启RDB和AOF持久化功能。使用Swap:Redis所在的主机操作系统如果使用了Swap(请求分页/分段,当内存不足时把部分内存数据换到磁盘上),这时Redis基本上已经无法提供高性能的服务。解决方案:Swap一般是由内存不足导致,所以需要整理内存空间,释放Redis的Swap过程通常要重启,为了避免重启对业务的影响,一般要先进行主从切换,然后释放旧主节点的内存空间,再重新启动服务,待数据同步完成后再切换回主节点
网卡负载过高:如果网卡负载过高,在网络层和TCP层就会出现数据发送延迟、数据丢包等情况。Redis的高性能除了内存之外,就在于网络IO,请求量突增会导致网卡负载变高。解决方案:首先需要排查这个机器上哪个Redis实例的流量过大占满了网络带宽,确认流量突增是否属于业务正常情况,如果属于那就要及时扩容或迁移实例,避免这个机器的其他实例受到影响,并且在运维方面要对极其各个指标进行监控,在达到阈值时提前报警
Java集合
1.集合线程安全问题怎么解决
线程安全就是在多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时进行保护,其他线程不能进行访问直到该线程访问完,其他线程才可使用。不会出现数据不一致或者数据污染。
线程不安全就是不提供数据访问保护,多线程先后更改数据会产生数据不一致或者数据污染的情况。一般可以通过synchronized
关键字加锁同步控制,来解决线程不安全问题。 在JAVA集合中大部分的集合都是线程不安全的,比如ArrayList
、LinekedList
、HashMap
,要保证线程安全可以采用如下方法
- 使用
Vector
集合
Vector
和ArrayList
类似,是长度可变的数组,与ArrayList
不同的是,Vector是线程安全的,它给几乎所有的public
方法都加上了synchronized
关键字。由于加锁导致性能降低,在不需要并发访问同一对象时,这种强制性的同步机制就显得多余,所以现在Vector基本已被弃用
- 使用
HashTable
集合
HashTable
和HashMap
类似,不同点是HashTable
是线程安全的,它也给几乎所有public
方法都加上了synchronized
关键字,因此它现在也因性能原因基本被弃用了
- 采用
Collections
工具类的包装方法
Vector
和HashTable
被弃用后被ArrayList
和HashMap
代替,但它们不是线程安全的,所以Collections
工具类中提供了相应的包装方法如Collections.synchronizedList()
、 Collections.synchronizedMap()
将它们包装成线程安全的集合,Collections
针对每种集合都声明了一个线程安全的包装类,在原集合的基础上添加了锁对象,集合中的每个方法都通过这个锁对象实现同步
- 使用
java.util.concurrent
包的集合
JUC包中常用的线程安全集合类有ConcurrentSkipListMap
、ConcurrentSkipListSet
、ConcurrentLinkedQueue
、ConcurrentLinkedDeque
等等
ConcurrentHashMap
和HashTable
都是线程安全的集合,它们的不同主要是加锁粒度上的不同。Ha1shTable
的加锁方法是给每个方法加上synchronized
关键字,这样锁住的是整个Table对象。而ConcurrentHashMap
是更细粒度的加锁.在JDK1.8之前,ConcurrentHashMap
加的是分段锁,也就是Segment锁,每个Segment含有整个table的一部分,这样不同分段之间的并发操作就互不影响JDK1.8对此做了进一步的改进,它取消了Segment字段,直接在table元素上加锁,实现对每一行进行加锁,进一步减小了并发冲突的概率
CopyOnWriteArrayList
和CopyOnWriteArraySet
则都是加了写锁的ArrayList
和ArraySet
,锁住的是整个对象,但读操作可以并发执行
JUC包中没有
ConcurrentArrayList
的原因是无法设计一个通用的而且可以规避ArrayList
的并发瓶颈的线程安全的集合类,只能锁住整个list,这用Collections
里的包装类就能办到
总结
- 线程安全与否如何取舍:要保证线程安全必须用
synchronized
关键字或类似的锁来进行同步控制- 当不需要线程安全(如只有单线程访问集合数据)时,可以选择线程不安全的集合类,避免方法同步的开销
- 当需要线程安全时(如多线程操作访问同一集合数据)时,可以选择上述任意一个保证线程安全的方法
- 线程不安全不等于不安全:线程不安全并不是多线程环境下就不能使用。要注意线程不安全的条件是多线程操作同一个对象。比如多线程分别操作不同的集合则使用线程不安全集合也是没有问题的
- 线程安全不等于完全安全:较复杂的操作下,线程安全的集合对象也无法保证数据的同步,仍需要进一步处理
2.java类型擦除
Java在编译后的字节码文件(.class)中是不包含泛型中的类型信息的,使用泛型的时候加上的类型参数,会被编译器在编译的时候去掉,这个过程就称为类型擦除。如在代码中定义的List<Object>
和List<String>
等类型,在编译之后都会变成List,JVM看到的只是List,而由泛型附加的类型信息对JVM来说是不可见的。
3. ArrayList
和LinkedList
使用场景
- 对数据有较多的随机访问时,选择
ArrayList
- 对数据有插入或删除操作较多,读取操作较少,选择
LinkedList
- 不过
ArrayList
的插入,删除操作也不一定比LinkedList慢,如在List靠近末尾的地方插入元素,则ArrayList
只需要移动较少的数据,而LinkedList
需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList
就比LinkedList
要快
4.哈希冲突的解决方法
哈希码是一个整数值,Java通过使用散列码从基于散列的集合中有效地检索数据。Object类有一个返回int的hashCode()方法,它是对象的哈希码。该方法默认实现通过将对象的内存地址转换为整数来计算对象的哈希码
- 链地址(拉链)法:这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
- 再哈希法:就是同时构造多个不同的哈希函数: Hi = RHi(key) i= 1,2,3 … k; 当H1 = RH1(key) 发生冲突时,再用H2 = RH2(key) 进行计算,直到冲突不再产生,这种方法不易产生聚集,但是增加了计算时间
- 建立公共溢出区:将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
- 开放地址法:从发生冲突的那个单元起,按照指定规则,从哈希表中找到一个空闲的单元。然后把发生冲突的元素存入到该单元的一种方法,按规则可分为如下
- 线性探测法:每次冲突则向下寻找1个位置,直到找到不冲突的位置,容易产生一次聚集的现象(数据集中在某一个地址区域)
- 平方探测法:每次冲突按平方寻找下一个位置,直到找到不冲突的位置
- 双散列:即发生冲突后使用第二个散列函数计算下一个位置
5.ArrayList
扩容机制
ArrayList
有三个构造方法,用不同构造方法创建元素存放数组elementData
的容量是不同的
ArrayList(int initialCapacity)
: 传入初始容量,会先判断这个传入的值,如果大于0就new一个新的Object数组,如果等于0就设置elementData
为EMPTY_ELEMENTDATA
ArrayList()
:不传入初始容量就使用默认容量10并设置elementData
为DEFAULTCAPACITY_EMPTY_ELEMENTDATA
ArrayList(Collection<? extends E> c)
: 传入一个Collection
,则会调用toArray()
方法把该参数转换成一个数组并赋值给elementData
,并且也会判断它的长度是否为0,如果为0设置elementData
为EMPTY_ELEMENTDATA
扩容:ArrayList
通过grow()
方法实现扩容。在该方法内部调用了Arrays.copyOf
方法来扩充数组容量的,扩充的数组容量大小由newCapacity(int minCapacity)
方法获取。默认情况下,新的容量会是原容量的1.5倍,这里用了位运算提高效率:newCapacity = oldCapacity + (oldCapacity >> 1)
。如果扩容1.5倍后就大于期望容量,那就返回这个1.5倍旧容量的值。而如果小于期望容量,那就返回期望容量。使用1.5倍这个数值而不是直接使用期望容量,是为了防止频繁扩容影响性能。
手动扩容:使用ensureCapacityInternal()
方法可以实现手动扩容,在其中会调用grow()
方法。之所以要手动扩容是因为如果已知要存入大量的元素,使用自动扩容可能需要扩容多次,而手动扩容只要一次就可达成,从而提高性能。
缩容:ArrayList
没有缩容。无论是remove()
方法还是clear()
方法都不会改变现有数组elementData
的长度。但是它们都会把相应位置的元素设置为null
,以便垃圾收集器回收掉不使用的元素,节省内存。
6.HashMap
负载因子为什么是0.75
HashMap的实例具有两个影响其性能的参数:初始容量和负载因子。容量是哈希表中存储桶的数量,初始容量只是哈希表创建时的容量。负载因子是在自动增加其散列表容量之前允许散列表获得的满度的度量。当哈希表中的元素数超过(负载因子*当前容量)的乘积时,哈希表将被重新哈希(也就是扩容,会扩充容量到原来的两倍,左移1位)。而默认负载因子选择为0.75是因为这个取值在时间和空间成本之间提供了一个很好的权衡
如果负载因子过高,如负载因子是1.0时,数组的每个位置都被填充后才会发生扩容,此时Hash冲突是避免不了的。大量的Hash的冲突会使底层的链表/红黑树变得异常复杂,对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。
如果负载因子过低,如负载因子是0.5时,数组中的元素达到一半就开始扩容,此时会发生的Hash冲突也减少了,底层的链表/红黑树的高度就会降低。查询效率就会增加。但是需要的内存空间就变多了(有一半的位置是空的没有利用到)。这种情况就是牺牲了空间来保证时间的利用率。
而负载因子是0.75时,取值适中,空间利用率和查询效率都比较高,且能避免了较多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。
7.ConcurrentHashMap
底层实现
JDK1.7版本ConcurrentHashMap
的数据结构是由一个Segment
数组和多个HashEntry
组成,Segment
数组的意义就是将一个大的table分割成多个小的table来进行加锁,而每一个Segment
元素存储的是HashEntry
数组+链表,这个和HashMap
的数据存储结构一样
JDK1.8版本的ConcurrentHashMap
的数据结构改为synchronized
+CAS+HashEntry
+红黑树,在元素没有头结点无法用synchronized
的情况下,通过CAS操作来设值,同时保证并发安全,如果元素里面已经存在值的话就直接使用synchronized
关键字对元素加锁
两个版本之间的区别
- JDK1.8的实现降低锁的粒度,粒度基于
HashEntry
(首节点),使用synchronized
来进行同步而JDK1.7版本锁的粒度是基于Segment
的,包含多个HashEntry
- JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
- JDK1.7的
Segment
实现了ReentrantLock
接口,而JDK1.8使用内置锁synchronized
来代替重入锁ReentrantLock
8.HashMap线程安全问题
HashMap是线程不安全的,其主要体现:
- 在JDK1.7中的多线程环境下,扩容时会造成环形链或数据丢失
- 在JDK1.8中的多线程环境下,插入时会发生数据覆盖
- 扩容出现死循环:JDK1.7的死循环发生在HashMap的扩容函数
transfer()
中,在扩容时需要将原来table中的数据逐个映射到两倍大小的桶数组newTable(数据会重新哈希分配到对应位置),并在插入时使用头插法。如果是多线程操作同一个HashMap,比如说一个线程已经实现扩容后,另一个线程再次进行扩容,也就是会重新进行链表指向,由于前面线程完成好扩容后链表是从前指向后,而新线程在扩容时采用头插法则是从后指向前,也就会导致链表的指向会形成一个环,这样之后一旦有操作需要遍历链表,就会也就是链表形成死循环的关键点。 - 扩容造成数据丢失:JDK1.7的数据丢失同样发生在HashMap的扩容过程中,比如说一个线程已经实现扩容后链表的最末尾元素的next从有值变成为空值,另一个线程扩容时采用这个末尾元素的next,结果导致后面本该插入的数据变成空值,也就导致了数据丢失。
- 插入时数据覆盖:JDK1.8对HashMap进行了优化,不再采用头插法方式,而是选择尾插法,因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全。在其
put()
操作源码中如果插入数据没有出现哈希冲突则会直接插入元素。而这时如果线程A和线程B同时进行put()
操作,这两条不同的数据的hash值又刚好一样,并且该位置数据为null,这时线程A、B都会进入直接插入的代码部分,因此也就可能导致线程A(B)会把线程B(A)插入的数据给覆盖。
9.HashMap扩容流程
HashMap扩容原因:数组大小不变,存储键值对越多则查找的效率越低,因为出现哈希冲突会使数组上的链表不断变长,而这时通过扩容分裂链表就可以提高查找效率
- JDK1.7扩容:HashMap 中默认的负载因子为 0.75,默认情况下第一次扩容阀值是 12(16 * 0.75),所以当存储第 13 个键值对时就会触发扩容机制,使桶数组变为原来的二倍。整个扩容过程就是取出每个数组元素,然后遍历以该元素为头的单向链表元素,对链表上的被遍历元素用
indexFor()
方法进行重新哈希计算得到新的对应数组下标,然后用头插法插入到新数组下标中(采用该方法插入会使单向链表的尾部变成头部,这也是引发HashMap线程不安全问题的原因)。 - JDK1.8扩容:在扩容中通过判断原来的 hash 值与新桶数组大小(原数组两倍)按位与
&
操作结果是否为0,如果为0则对应数组下标不变,否则对应数组下标变成原下标加上原数组大小。另外JDK1.8采用的是尾插法,在扩容时不会出现链表倒置的现象。且JDK1.8为了性能在同一下标处发生哈希冲突到一定程度时链表结构会转换为红黑数结构来存储冲突元素
10.HashMap的writeObject()
和readObject()
HashMap实现了Serializable接口,这意味着该类可以被序列化,而JDK提供的用于Java对象序列化操作的类是ObjectOutputStream和ObjectInputStream,这两个类在进行序列化操作时,会判断被序列化的对象是否重写了writeObject()
/readObject()
,如果重写了就会调用被序列化对象的writeObject()
方法,如果没有重写则调用默认的序列化方法。
HashMap重写writeObject()
和readObject()
的原因:Java对象序列化往往是为了实现跨机器的数据传输,而其中最基本的要求就是反序列化之后的对象与序列化之前的对象是一致的。而在HashMap中,由于Entry的存放位置是根据Key的Hash值来计算然后存放到数组中的,对于同一个Key,在不同的JVM(机器)中计算得出的Hash值可能是不同的。结果就使HashMap对象的反序列化结果与序列化之前的结果不一致。为了避免这个问题,HashMap采用以下方式解决:
- 采用
transient
关键字。修饰可能会造成数据不一致的元素,从而避免JDK中默认序列化方法对该对象的序列化操作,HashMap中添加修饰的成员有:table
、size
、modCount
- 重写
writeObject()
和readObject()
方法。从而保证序列化和反序列化结果的一致性
HashMap在序列化时不将保存数据的数组序列化,而是将元素个数以及每个元素的Key和Value都进行序列化。 在反序列化时重新计算Key和Value的位置,重新填充一个数组。 由于不序列化存放元素的Entry数组,而是反序列化的时候重新生成,这样就避免了反序列化之后根据Key获取到的元素与序列化之前获取到的元素不同。
JVM
1.JVM方法区作用
JVM中的方法区与堆一样,是线程共享的内存区域。方法区在JVM启动时就会被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的;方法区的大小跟堆空间一样,可以选择固定大小或者可拓展,并且方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出的OOM错误,定义过多类的原因可能如下,当关闭JVM后就会释放方法区中的内存。
- 加载大量的第三方jar包
- Tomcat部署的工程过多
- 大量动态生成反射类
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型( 类class
、接口interface
、枚举enum
、注解annotation
),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java. lang.Object,都没有父类)
- 这个类型的修饰符(public, abstract, final的某个子集)
- 这个类型直接接口的一个有序列表
域信息
域信息即类的成员变量
- JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
- 域的相关信息包括:域名称、 域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)
方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
- 方法名称。
- 方法的返回类型(或void)。
- 方法参数的数量和类型(按顺序)。
- 方法的修饰符(public, private, protected, static, final, synchronized, native , abstract)的一个子集
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小( abstract和native 方法除外)。
- 异常表( abstract和native方法除外),每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。
静态变量
- 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
- 类变量被类的所有实例所共享,即使没有类实例你也可以访问它。
全局常量
被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就被分配了。
运行时常量池
首先介绍常量池的概念。在一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型域和方法的符号引用。一个 java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池;而这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。 字节码中的常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量等信息
常量池内存储的数据类型包括:数量值、字符串值、类引用、字段引用、方法引用
- 运行时常量池是方法区的一部分。
- 常量池表是字节码文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
- JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的。
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。运行时常量池相对于Class文件常量池的另一重要特征是具备动态性。
- 运行时常量池类似于传统编程语言中的符号表(symbol table) ,但是它所包含的数据却比符号表要更加丰富一些。
- 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。
2.为什么将永久代替换为元空间
方法区又称永久代。在jdk8中已经废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间来代替永久代。 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
- 虚拟机合并。在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
- 为永久代设置空间大小是很难确定的。 在某些场景下,永久代如果动态加载类过多,容易产生OOM。而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此默认情况下,元空间的大小仅受本地内存限制,可以加载更多的类,也不容易出现OOM错误
- 对永久代进行调优是很困难的。
3.字符串常量池为什么要移到堆
在JDK7版本中JVM常量池中的字符串常量池StringTable单独抽离出来并移动到堆中。这是因为永久代的回收效率很低,在full gc的时候才会触发。而full GC 是老年代的空间不足、永久代不足时才会触发。这就导致了StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
4.JVM分代,新生代老年代是什么
Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代和永久代(对HotSpot虚拟机而言),这就是JVM的内存分代策略。
分代原因: 堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率。
但是有了JVM分代,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。
新生代:新生成的对象优先存放在新生代中,新生代对象存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。HotSpot将新生代划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。
老年代:在新生代中经历了多次(具体看虚拟机配置的阀值),GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。
永久代:永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。
5.垃圾回收怎么判断是否要回收
引用计数法:为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。 在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
可达性分析法:可达性分析算法的实质在于将一系列 GC Roots 作为初始的存活对象合集,然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。
6.GCRoot对象有哪些
GC管理的主要区域是Java堆。方法区、栈和本地方法区不被GC所管理,因而选择这些区域内的对象作为GC roots,被GC roots引用的对象不被GC回收。
- 虚拟机栈(栈桢中的本地变量表)中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI的引用的对象
7.垃圾回收的stw是什么
stw:stop-the-world。不管选择哪种GC算法,stop-the-world都是不可避免的。stop-the-world意味着从应用中停下来并进入到GC执行过程中去。一旦stop-the-world发生,除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务。如果内存中的垃圾堆积,可用内存越来越少,最后导致full GC 然后stop the world。GC调优通常就是为了改善stop-the-world的时间。
8.GC分类
- Minor GC:只回收新生代的GC,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
- Old GC:只回收老年代的GC。只有CMS垃圾收集器的并发回收是这个模式
- Mixed GC:回收所有新生代和部分老年代的GC。只有G1垃圾收集器有这个模式
- Full GC:回收整个堆(包括新生代、老年代、永久代)的GC,由于老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
9.堆的内存分配策略
- 对象优先在Eden分配:大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
- 大对象直接进入老年代:大对象是指需要连续内存空间的对象,典型的大对象是那种很长的字符串以及数组。 经常出现大对象会提前触发GC以获取足够的连续空间分配给大对象。比如-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
- 长期存活的对象进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。 -XX:MaxTenuringThreshold 用来定义年龄的阈值。
- 动态对象年龄判定:虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
- 空间分配担保:在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立则Minor GC可确认是安全的。 如果不成立的话虚拟机会查看HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将尝试着进行一次 Minor GC;如果小于或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
10.Full GC的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
- 调用 System.gc() :只该方法是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而应该让虚拟机自己管理内存。
- 老年代空间不足:老年代空间不足发生在大对象或长期存活的对象进入老年代空间时。 为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
- 空间分配担保失败:使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第 5 小节。
- JDK 1.7 及以前的永久代空间不足:在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放类信息、常量、静态变量等数据。 当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。 为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
- Concurrent Mode Failure:执行 CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
11.JVM内存空间划分
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。这些组成部分一些是线程私有的,其他的则是线程共享的。
线程私有
程序计数器:可以看作当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储
程序计数器两个关键作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如顺序执行、选择等
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
程序计数器是唯一一个不会出现
OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
虚拟机栈:它的生命周期和线程相同,描述的是 Java 方法执行的内存模型。Java虚拟机栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。局部变量表主要存放编译器可知的各种数据类型、对象引用。Java虚拟机栈会出现
StackOverFlowError
和OutOfMemoryError
这两种异常本地方法栈:和虚拟机栈作用非常相似,区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现
StackOverFlowError
和OutOfMemoryError
这两种异常
线程共享
- 堆:Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆
- 方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 直接内存:适合频繁的IO操作。如JDK1.4中新加入的 NIO,引入了一种基于通道与缓存区的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。
12.类加载器的分类
- 启动类加载器Bootstrap ClassLoader:此类加载器负责将存放在
\lib 目录中的,或者被 -Xbootclasspath
参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。 - 扩展类加载器Extension ClassLoader这个类加载器是由
ExtClassLoader
实现的。它负责将/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。 - 应用程序类加载器Application ClassLoader这个类加载器是由
AppClassLoader
实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
13.常用JDK调试工具
jvm调试与定位经常会用到jdk自带的工具,对于理解内存管理,垃圾回收机制,java线程堆栈,内存溢出,泄漏,死锁等问题的定位很有帮助。
监控类工具:用于监控jvm性能参数
- jps:JVM Process Status Tool ,用于输出JVM中运行的进程状态信息,能够输出线程号和main方法
- jstat: JVM Statistics Monitoring Tool,用于监控虚拟机的各种运行状态信息,如类的装载、内存、GC、JIT编译器等,常用于查看gc信息(比如eden、from、to、old 等区域的内存使用情况),
jstat -gc {pid} {interval} {count}
调试类工具 :用于定位某种特定的问题
- jinfo:Configuration Info,用于打印java进程的配置信息,包括java系统属性、java虚拟机命令行表示参数。可查看和调整虚拟机各项参数。 jinfo -sysprops {pid}
- jhat:Heap Dump Brower,常用于jmap dump后的快照文件打开分析
- jmap:Memory Map,用于查看堆内存使用状况
- jstack:Stack Trace ,用于查看某个java进程中的线程堆栈信息,为当前时刻的快照,常用来找出java进程中最耗费CPU的线程, 也可用于定位死锁,死循环等问题
14.new一个对象的过程
java在new一个对象的时候,会先查看对象所属的类有没有被加载到内存(运行时常量池),如果没有的话,就会先通过类的全限定名来加载。加载并初始化类完成后,再进行对象的创建工作。假设第一次使用类,则new一个对象就可以分为两个过程:加载并初始化类和创建对象。
类加载过程
加载:由类加载器负责根据一个类的全限定名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区的方法区,然后将其转换为一个与目标类型对应的java.lang.Class对象实例
验证:
- 格式验证:验证是否符合class文件规范
- 语义验证:检查一个被标记为final的类型是否包含子类;检查一个类中的final方法是否被子类进行重写;确保父类和子类之间没有不兼容的一些方法声明(比如方法签名相同,但方法的返回值不同)
- 操作验证:在操作数栈中的数据必须进行正确的操作,对常量池中的各种符号引用执行验证(通常在解析阶段执行,检查是否可以通过符号引用中描述的全限定名定位到指定类型上,以及类成员信息的访问修饰符是否允许访问等)
准备: 为类中的所有静态变量分配内存空间,并为其设置一个初始值(由于还没有产生对象,实例变量不在此操作范围内)被final修饰的static变量(常量),会直接赋值;
解析:将常量池中的符号引用转为直接引用(得到类或者字段、方法在内存中的指针或者偏移量,以便直接调用该方法),这个可以在初始化之后再执行。解析需要静态绑定的内容(所有不会被重写的方法和域都会被静态绑定)
以上2、3、4三个阶段又合称为链接阶段,链接阶段要做的是将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中。
初始化:(先父后子)
- 为静态变量赋值
- 执行static代码块
注意:static代码块只有jvm能够调用,如果是多线程需要同时初始化一个类,仅仅只能允许其中一个线程对其执行初始化操作,其余线程必须等待,只有在活动线程执行完对类的初始化操作之后,才会通知正在等待的其他线程。
因为子类存在对父类的依赖,所以类的加载顺序是先加载父类后加载子类,初始化也一样。不过,父类初始化时,子类静态变量的值也有有的,是默认值。
最终,方法区会存储当前类类信息,包括类的静态变量、类初始化代码(定义静态变量时的赋值语句 和 静态初始化代码块)、实例变量定义、实例初始化代码(定义实例变量时的赋值语句实例代码块和构造方法)和实例方法,还有父类的类信息引用。
创建对象
- 在堆区分配对象需要的内存:分配的内存包括本类和父类的所有实例变量,但不包括任何静态变量
- 对所有实例变量赋默认值:将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值
- 执行实例初始化代码:初始化顺序是先初始化父类再初始化子类,初始化时先执行实例代码块然后是构造方法
- 如果有类似于Child c = new Child()形式的c引用的话,在栈区定义Child类型引用变量c,然后将堆区对象的地址赋值给它。需要注意的是,每个子类对象持有父类对象的引用,可在内部通过super关键字来调用父类对象,但在外部不可访问
15.java的编译和执行
Java程序从源文件创建到程序运行要经过以下步骤:
- Java文件由编译器(javac)编译成字节码文件,编译过程会经过词法分析、语法分析、语义分析和中间代码生成
- 然后字节码文件会由Java虚拟机(JVM)进行一定优化生成目标代码后解释运行,一些常用的代码还会用即时编译器编译
因为java程序既要编译也要经过JVM的解释运行,所以说Java被称为半解释语言
16.双亲委派模型
双亲委派模型:除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。双亲委派模型的工作过程如下
- 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
- 每一个层次的类加载器都是如此。因此所有的加载请求最终都应该传送到顶层的启动类加载器中。
- 只有当父加载器反馈自己无法完成这个加载请求时(搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。
作用:使类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object
,它由启动类加载器加载。双亲委派模型保证任何类加载器收到的对java.lang.Object
的加载请求,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。如果没有使用双亲委派模型则系统中可能会出现多个不同的Object类
17.需要自定义类加载器的应用场景
需要进行自定义类加载器的场景如下
- 加密:Java代码可以轻易的被反编译,如果需要把代码进行加密以防止反编译,可以先将编译后的代码用某种加密算法加密,类加密后就不能再用Java的ClassLoader去加载类了,这时就需要自定义ClassLoader在加载类的时候先解密类,然后再加载。
- 从其他来源加载代码:不一定是从字节码文件中读取类,也可能将字节码放到数据库、甚至是在云端来读取类,而通过自定义类加载器就可以从指定的来源加载类。
- 以上两种情况结合:当应用需要通过网络来传输字节码时,为了安全性要对字节码进行加密处理,这时就需要自定义类加载器来从某个网络地址上读取加密后的字节代码,再进行解密和验证,最后定义出在Java虚拟机中运行的类。
18.finalize()
方法
Java的垃圾回收器只会释放由new出来的内存堆块,而不会管理那些不是由new出来的特殊内存。特殊内存通常是指通过JNI向系统申请的内存,这些内存如果不手动去清除就会一直占据在内存中。如果不想要占据特殊内存的对象时,就需要手动处理,Java允许定义这样的方法即finalize( )
,其专门用于清除回收内存。
- 用途
- 清理本地对象(通过JNI创建的对象)
- 确保某些非内存资源(如Socket、文件等)的释放
- 特点
System.gc()
与System.runFinalization()
方法增加了finalize()
执行的机会,但不要随意使用- Java语言规范不保证
finalize()
会被及时执行、也不会保证其会被执行 finalize()
可能会带来性能问题,因此JVM通常在单独的低优先级线程中完成finalize()
的执行finalize()
可将待回收对象赋值给GC Roots
可达的对象引用,从而达到对象再生的目的finalize()
至多由GC执行一次(用户可以手动调用对象的finalize()
,但并不影响GC对finalize()
的行为)
- 执行过程:当对象变成GC Roots不可达时,GC会判断该对象是否重写了
finalize()
,若未重写则直接将其回收。否则若对象未执行过finalize()
,将其放入F-Queue队列,由一低优先级线程执行该队列中对象的finalize()
。执行finalize()
完毕后,GC会再次判断该对象是否可达,若不可达则进行回收,若可达则使对象再生
19.JVM调优原因分析以及相应解决方案
一般来说JVM调优原因的问题不会直接问,而是转换成如下形式
- 如果系统CPU/内存占用100%了怎么办?
- 如果系统忽然不能响应了怎么排查?
- 如果系统压测数据上不去除了加负载还有没有其他的好办法?
CPU占用过高
解决思路:首先找到 CPU 飚高的那个 Java 进程,因为你的服务器会有多个 JVM 进程。然后找到那个进程中1的问题线程,最后根据线程堆栈信息找到问题代码。最后对代码进行排查。具体流程如下:
- 通过 top 命令找到 CPU 消耗最高的进程并记住进程 ID。
- 再次通过 top -Hp [进程 ID] 找到 CPU 消耗最高的线程 ID并记住线程 ID.
- 通过 jstack 工具dump线程堆栈信息到指定文件中:
jstack -l [进程 ID] > jstack.log
- 由于第2步中记录的线程 ID 是10进制,而堆栈信息jstack.log中线程 ID 是16进制,因此需要将10进制转换成16进制再去查找相关信息
- 通过转换后的16进制数字从堆栈信息jstack.log里找到对应的线程堆栈,分析其中存在的问题。
可能原因及解决办法
- 某个业务存在死循环没有出口。这种情况可以根据业务进行修复。
- 即时编译器在执行编译时抢占 CPU。当 Java 某一段代码执行次数超过10000次(默认)后,即时编译器就会将该段代码从解释执行改为编译执行,也就是编译成机器码以提高速度。这种情况在项目上线后,可以先通过压测工具进行预热先生成编译代码
- GC 线程导致CPU占用高,这时可能是频繁出现Full GC 。这种情况就要进行GC的优化。
内存问题排查
Java 的内存由 GC 管理。因此内存问题通常有2种情况:内存溢出、内存未溢出但GC不正常
内存溢出
可以通过加上-XX:+HeapDumpOnOutOfMemoryError
参数,该参数作用是在程序内存溢出时输出 dump 文件。有了 dump 文件就可以通过 dump 分析工具进行分析了,比如常用的MAT,Jprofile,jvisualvm 等工具都可以分析,这些工具都能够看出到底是哪里溢出,哪里创建了大量的对象等等信息。
GC不正常
通常正常的GC :minor GC 5秒左右触发一次,每次不超过50毫秒;full GC 最好没有。对于GC的优化有2个维度,一是频率,二是时长。
minor GC 频率:对于minor GC ,触发频率很低(比如触发间隔超过5秒),说明系统占用内存过大,应该缩小容量;如果触发频率很高,说明Eden区过小,可以将 Eden区内存增大,但整个新生代的容量应该设置在堆的 30% - 40%之间,eden,from 和 to 的比例应该在 8:1:1左右,这个比例可根据对象晋升的大小进行调整。
minor GC 时长:minor GC时间过长,minor GC有2个过程,一个是扫描,一个是复制,通常扫描速度很快,复制速度相比而言要慢一些。minorGC时长较长的原因
- 如果每次都有大量对象要复制,就会将 STW 时间延长
- minor GC 每次都会扫描StringTable (这个数据结构存储着 String.intern 方法返回的常量连接池的引用),如果该数据结构很大,且没有经过full GC,那么也会拉长 STW 时长
- 操作系统的虚拟内存导致,当 GC 时正巧操作系统正在交换内存,也会拉长 STW 时长。
20.JVM调优相关配置
关于JVM调优考虑的因素,主要有内存管理和垃圾回收两个方面,两者并不互相独立,内存的大小配置也会影响垃圾回收的执行效率
内存管理
内存大小的占用是指应用程序启动后稳定运行一小段时间时,观察到的内存占用情况。以 HotSpot 虚拟机为例,Java 堆主要有三个空间:新生代、老年代和永久代。 要根据不同应用的特点,观察应用对于内存的占用,如果有大量的临时对象,不会重复使用,则可以调整新生代大小, 这样这些临时对象就在新生代创建完成,并在 Minor GC 产生时被回收,这样较短生存活的对象不会晋升到老年代,从而可以避免垃圾堆集产生 Full GC。理想状态下,短期存活的对象都只在新生代完成生命周期,被开销小的Minor GC回收完成, 而长期存活,将会多次使用的在多次回收之后晋升到老年代, 最终经过 Full GC 完成生命周期。
设置内存占用大小
内存大小的调整参数有:
-Xms
、-Xmx
,这两个参数用于配置堆的起始大小和最大值。这里需要经过观察找一个合适的值,设置太大会导致内存浪费,同时也会导致垃圾回收耗时太长。对于 Tomcat 来说,一般都会将初始值和最大值设置为相同值,这样就避免在初始内存不足时触发 Full GC 来进行扩展内存。设置内存中各个代的大小划分
设定内存占用大小后,要根据对象生命周期的特征来调整新生代与老年代的大小比例,调整参数有
-XX:NewSize
、-XX:NewRatio
、-XX:MaxNewSize
、-Xmn
,这些参数分别对应新生代起始大小,新生代比例、新生代最大值、新生代的初始值和最大值(两者相同)。和前面堆内存的设置一样,太高或太低都会导致 GC 不能高效的工作。对于使用了大量第三方类库的应用来说,会加载许多框架依赖的类,使用过程中可能会遇到因为永久代空间不足产生的 OOM,这种情况可以通过观察稳定状态下永久代占用,再通过参数设置。
-XX:PermSize
、-XX:MaxPermSize
、-XX:MaxMetaspaceSize
,这些参数分别对应永久代起始大小和永久代最大值。在Java 8中永久代被移除改为元空间,不过如果遇到类似的OOM,依然可以调整其大小。
垃圾回收
不同的垃圾回收算法,对于应用的影响很大。比如在一个服务器上却使用了单线程的回收算法;或是应用对于响应要求很高,但却使用了一个吞吐量优先的算法,导致响应太慢。所以对于垃圾回收算法的选择,一般都是根据应用的特点,是要低延迟还是高吞吐量,选择合适的算法。前面提到垃圾回收算法和内存的大小配置并非独立的,内存设置大是回收的频率会降低,但每次的执行时间也会变长。所以这里也是一个需要权衡的地方。
- 延迟、吞吐量调优
- 其他 JVM 配置
垃圾回收算法对应到的就是不同的垃圾收集器,具体到在 JVM 中的配置,是使用-XX:+UseParallelOldGC
或者-XX:+UseConcMarkSweepGC
这种不同的收集器来达到选择算法的目的。其中 ParallelGC 也称为吞吐量优先收集器,可以提升应用的吞吐量,但可能不能满足应用的低延迟要求。而ConcMarkSweepGC也称为CMS GC(并发标记收集器),其可以做到老年代的垃圾回收与应用程序的并行执行,所以可以实现低延迟。(要注意由于 CMS GC 和其他GC回收算法使用的框架不同,因此不能混用,在使用CMS 进行老年代回收时,新生代默认使用了单线程回收算法,此时可以通过配置 -XX:+UseParNewGC来使用 新生代并行回收。
由于CMS是垃圾回收和应用线程并行,因此需要额外的CPU处理资源,如果只有一个CPU的机器,或者有多个忙碌的CPU,又想要使用低延迟的收集器,此时可以通过配置 CMS 收集器的增量模式来进行回收,通过指定-XX:+CMSIncrementalMode
来开启增量模式。此时交替运行垃圾收集器应用线程。通过配置-XX:CMSIncrementalSafetyFactor=X
, -XX:CMSIncrementalDutyCycleMin=Y
,
-XX:CMSIncrementalPacing
可以控制垃圾收集后台线程为应用线程让出多少CPU周期。参数-XX:+CMSParallelRemarkEnabled
用来降低标记停顿,另外由于CMS 回收后的老年代内存空间并不是连续的,因此通过参数-XX:+UseCMSCompactAtFullCollection
在Full GC的时候对年老代的压缩。
在JDK1.7 的时候引入了 G1 收集器,可以通过配置-XX:+UseG1GC
来开启
21.JVM调优实操
前面说了一些JVM调优时需要考虑的因素,在实际操作的时候需要将这些配置写在配置文件中,Windows下是修改Tomcat安装目录下conf文件夹中的catalina.bat文件, Linux下是catalina. sh文件。
catalina. sh中实际启动时执行的命令如下
1 | eval exec "\"$_RUNJAVA\"" "\"$LOGGING_CONFIG\"" $LOGGING_MANAGER "$JAVA_OPTS" "$CATALINA_OPTS" \ |
在实际配置时要注意不要改变之前已有的配置,比如在配置JAVA_OPTS的时候,要把前面已经配置的加上如下指令表示使用CMS垃圾收集器并且设置堆起始大小为500MB
1 | JAVA_OPTS="$JAVA_OPTS $JSSE_OPTS" |
22.JVM中可能会出现的内存溢出问题
堆溢出:堆中存放对象,不可用的对象会由GC被回收掉,若存在大量的对象导致空间不足则会发生OOM。一般原因可能是内存泄露或是内存溢出,内存泄露则要定位源头,内存溢出则可以通过修改JVM的堆内存大小
虚拟机栈和本地方法栈溢出:栈中有可能发生两种异常
- StackOverFlow:当线程请求的栈深度超出了最大深度时,方法无限递归且未退出造成的
- OOM:当栈需要扩展时发现空间不够时。可通过
-Xss
参数改变栈容量(栈空间设置的越大,允许的线程数就越少)
方法区溢出:方法区中主要是类信息,静态变量等,当类、静态变量产生过多,就会发生OOM,或者是采用动态代理技术产生大量代理类,或者是加载过多的依赖包
常量池溢出:当常量池常量过多时,就会发生OOM。可用
-XX:PermSize
-XX:MaxPermSize
设置方法区大小,也可以间接设置常量池大小。直接内存溢出:用native方法申请堆外内存时申请过多会OOM,可用
-XX:MaxDirectMemorySize
参数调整,直接内存出现OOM在堆转存快照中看不到明显异常,并且文件很小,程序中又使用了NIO,则可以考虑可能发生的是直接内存的OOM。
程序计数器是JVM中唯一不会出现OOM的一块区域
23.字符串常量池
字符串在分配需要耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁地创建字符串,会极大程度地影响程序的性能。而JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了优化处理:即为字符串开辟一个字符串常量池(类似于缓存区)。这样当创建字符串常量时,首先会去字符串常量池检测是否存在该字符串,如果存在该字符串就返回引用实例,不存在则实例化该字符串并放入池中。
在JDK6.0及之前版本,字符串常量池存放在方法区中;在JDK7.0版本以后,字符串常量池被移到了堆中。因为程序中可能会大量频繁地创建字符串,而原本在方法区的内存空间就可能会不够用。字符串常量池中有一个表,总是为池中每个唯一的字符串对象维护一个引用,这就意味着表会一直引用字符串常量池中的对象,所以在常量池中的这些字符串不会被垃圾收集器回收
数据库
1.MySQL结构怎么划分
MySQL主要分为Server层和存储引擎层
Server层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binglog日志模块。
存储引擎: 主要负责数据的存储和读取,采用可以替换的插件式架构,支持InnoDB、MyISAM、Memory等多个存储引擎,其中InnoDB引擎有自有的日志模块redolog 模块。InnoDB 5.5.5版本作为默认引擎。
Server层内容概述
连接器:主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作,如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,后续只要这个连接不断开,即时管理员修改了该用户的权限,该用户也是不受影响的。
查询缓存:连接建立后,执行查询语句的时候,会先查询缓存,Mysql会先校验这个sql是否执行过,以Key-Value的形式缓存在内存中,Key是查询预计,Value是结果集。如果缓存key被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。当然在真正执行缓存查询的时候还是会校验用户的权限,是否有该表的查询条件。
MySQL查询不建议使用缓存,因为对于经常更新的数据来说,缓存的有效时间太短了,往往带来的效果并不好,对于不经常更新的数据来说,使用缓存还是可以的,Mysql 8.0 版本后删除了缓存的功能,官方也是认为该功能在实际的应用场景比较少,所以干脆直接删掉了。
分析器:MySQL没有命中缓存,那么就会进入分析器,分析器主要是用来分析SQL语句是来干嘛的,分析器也会分为几步:
- 第一步,词法分析。一条SQL语句有多个字符串组成,首先要提取关键字,比如select,提出查询的表,提出字段名,提出查询条件等等。做完这些操作后,就会进入第二步。
- 第二步,语法分析。主要就是判断你输入的sql是否正确,是否符合MySQL的语法。
完成这2步之后,mysql就准备开始执行了,但是如何执行,怎么执行是最好的结果呢?这个时候就需要优化器上场了。
优化器:优化器的作用就是它认为的最优的执行方案去执行(虽然有时候也不是最优),比如多个索引的时候该如何选择索引,多表查询的时候如何选择关联顺序等。
执行器:当选择了执行方案后,mysql就准备开始执行了,首先执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果。
2.一条SQL语句在MySQL中如何执行
SQL语句基本可以分为两种,一种是查询,一种是更新(增加、更新、删除)
查询语句
1 | select * from tb_student A where A.age='18' and A.name='张三'; |
- 先用连接器检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,在mysql8.0版本以前,会先查询缓存,以这条sql语句为key在内存中查询是否有结果,如果有直接缓存,如果没有,执行下一步。
- 通过分析器进行词法分析,提取sql语句的关键元素,比如提取上面这个语句是查询
select
,提取需要查询的表名为tb_student
,需要查询所有的列,查询条件是这个表的id='1'
。然后判断这个sql语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。 - 接下来就是优化器进行确定执行方案,上面的sql语句,可以有两种执行方案:
- 先查询学生表中姓名为“张三”的学生,然后判断是否年龄是18。
- 先找出学生中年龄18岁的学生,然后再查询姓名为“张三”的学生。
当优化器根据优化算法选择执行效率最好的一个方案后,就可以确认执行计划并开始执行。
- 执行器先进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果。
更新语句
1 | update tb_student A set A.age='19' where A.name='张三'; |
其实这条语句也基本上会沿着像查询那样的流程走,只不过执行更新时还需要记录日志,这时就会引入日志模块,MySQL自带的日志模块binlog,所有的存储引擎都可以使用,InnoDB引擎也自带了一个日志模块redo log,下面就以InnoDB模式下来探讨这个语句的执行流程。流程如下:
- 先查询到张三这一条数据,如果有缓存,也是会用到缓存。
- 然后拿到查询的语句,把 age 改为19,然后调用引擎API接口,写入这一行数据,InnoDB引擎把数据保存在内存中,同时记录redo log,此时redo log进入prepare状态,然后告诉执行器,执行完成了,随时可以提交。
- 执行器收到通知后记录binlog,然后调用引擎接口,提交redo log 为提交状态。
- 更新完成。
这里使用两个日志模块是有原因的。在MySQL以前的版本,数据库默认引擎是MyISAM,但是其没有redo log,因此不支持事务,而现在版本的InnoDB引擎就是通过redo log来支持事务的。且这里redo log的预提交状态和提交状态的设置也是有原因的,比如出现如下情况
- 先写redo log 直接提交,然后写 binlog。假设写完redo log 后,机器挂了,binlog日志没有被写入,那么机器重启后,这台机器会通过redo log恢复数据,但是这个时候binlog并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
- 先写binlog,然后写redo log。假设写完了binlog,机器挂了,由于没有redo log,本机是无法恢复这一条记录的,但是binlog又有记录,那么和上面同样的道理,就会产生数据不一致的情况。
如果采用redo log 两阶段提交的方式就不一样了,写完binglog后,然后再提交redo log就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?
- 先写redo log 预提交,然后写binlog。假设写完binlog后,机器挂了,这个时候没有redo log还未变为提交状态。则mysql也有相应的处理机制
- 判断redo log 是否完整,如果判断是完整的,就立即提交。
- 如果redo log 只是预提交但不是commit状态,这个时候就会去判断binlog是否完整,如果完整就提交 redo log, 不完整就回滚事务。这样就解决了数据一致性的问题。
查询语句执行流程:权限校验(如果命中缓存)—》查询缓存—》分析器—》优化器—》权限校验—》执行器—》引擎
更新语句执行流程:分析器—-》权限校验—-》执行器—》引擎—redo log(prepare 状态—》binlog—》redo log(commit状态)
3.一条SQL语句执行得很慢的原因
一个 SQL 执行的很慢,要分两种情况讨论
- 大多数情况下很正常,偶尔很慢,则有如下原因
- 数据库在刷新脏页,例如 redo log 写满了需要同步到磁盘。
- 执行的时候,遇到锁,如表锁、行锁。
- 这条 SQL 语句一直很慢,则有如下原因
- 没有用上索引。如该字段没有索引或由于对字段进行运算、函数操作导致无法用索引。
- 数据库选错索引
4.MySQL索引优化建议
索引目的:通过索引进行数据查找,减少随机IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少
索引一般添加在以下字段
- 出现在
SELECT、UPDATE、DELETE
语句的WHERE
从句中的列 - 包含在
ORDER BY、GROUP BY、DISTINCT
中的字段,通常会将1、2中的字段建立联合索引使查询效果更好 - 多表
join
的关联列
- 出现在
联合索引顺序
- 区分度最高的放在联合索引的最左侧(区分度=列中不同值的数量/列的总行数)
- 尽量把字段长度小的列放在联合索引的最左侧(因为字段长度越小,一页能存储的数据量越大,IO性能也就越好)
- 使用最频繁的列放到联合索引的左侧(这样可以比较少的建立一些索引)
要避免建立冗余索引和重复索引,从而增加了查询优化器生成执行计划的时间
尽量避免使用
or
关键字,可能会使索引失效,从而进行全表扫描。尽量避免在索引列上使用内置函数,会使索引失效
OR关键字可能使索引失效,是因为OR连接的条件字段需要全部添加索引才能使查询索引生效,如果有字段不加索引则不会使用查询索引。而AND关键字就只需要有一个条件字段有索引即可
5.三级封锁协议和两段锁协议
- 一级封锁协议 :要求事务在修改数据时必须加互斥锁(写锁),直到事务结束才释放锁。 可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。
- 二级封锁协议:在一级封锁协议的基础上,要求事务在读取数据时必须加共享锁(读锁),读取完马上释放共享锁。 可以解决读脏数据问题,因为如果一个事务在对数据进行修改,根据一级封锁协议,会加互斥锁,那么就不能再加共享锁了,也就是不会读入数据。
- 三级封锁协议:在二级封锁协议的基础上,要求事务在读取数据时必须加共享锁,直到事务结束了才能释放共享锁。 可以解决不可重复读的问题,因为读数据时,其它事务不能对数据加互斥锁,从而避免了在读的期间数据发生改变。
两段锁协议指每个事务的执行可以分为两个阶段:加锁阶段和解锁阶段。
- 加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得共享锁,在进行写操作之前要申请并获得互斥锁。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。
- 解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。
两段封锁法可以这样来实现:事务开始后就处于加锁阶段,一直到执行
ROLLBACK
和COMMIT之
前都是加锁阶段。ROLLBACK
和COMMIT
使事务进入解锁阶段,即在ROLLBACK
和COMMIT
模块中DBMS释放所有封锁。
6.当前读与快照读
MVCC(Multi-Version Concurrency Control):多版本并发控制。是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现读已提交和可重复读这两种隔离级别。而读未提交隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC;可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。MVCC在很多情况下能够避免加锁操作,降低了开销
当前读:指当前事务读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
快照读:即不加锁的非阻塞读,快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读,之所以出现快照读的情况,是基于提高并发性能的考虑,快照读是基于MVCC实现的,因此可能读到的不一定是数据的最新版本,也可能是历史版本
三者关系:MVCC多版本并发控制指维持一个数据的多个版本,使得读写操作没有冲突。在MySQL中实现MVCC需要提供具体的功能,而快照读就是MySQL为实现MVCC理想模型的其中一个具体非阻塞读功能,而当前读实际上是一种加锁的操作,是悲观锁的实现
优势:MVCC是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以采用MVCC在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能,同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
7.数据库范式
- 1NF 需要保证表中每个属性都保持原子性。即列不可再分,每一列都是不可分割的基本数据项。可通过分离冗余字段解决
- 2NF 需要保证表中的非主属性与候选键完全依赖。在第一范式基础上,对于多主键表,非主属性不能部分依赖于主键(依赖于某个主键),而对于单主键表,不存在部分依赖情况。可通过按主键分离表解决
- 3NF 需要保证表中的非主属性与候选键不存在传递依赖。在第二范式基础上,非主属性对任一主键不能传递函数依赖,即非主键列必须直接依赖于主键,在表中非主键列之间不能存在依赖关系。可通过按非主键字段分离表用依赖字段作为分离表的外键解决
- BCNF 需要保证表中的主属性与候选键不存在部分依赖或者传递依赖。在第三范式的基础上,不存在主键字段决定(依赖)主键字段的情况。可通过将组合主键分离到不同表解决
8.MySQL的索引类型
按数据结构划分
- B+Tree 索引:由于 B+Tree的有序性,除了查找,还可以用于排序和分组。 可以指定多个列作为索引列,多个索引列共同组成键。 适用于全键值、键值范围和键前缀查找,其中键前缀查找只适用于最左前缀查找
- hash索引:哈希索引能以 O(1) 时间进行查找,但失去了有序性,只支持精确查找,无法用于部分查找和范围查找以及排序、分组
- FULLTEXT索引:用于查找文本中的关键词,而不是直接比较是否相等。
- R-Tree索引:空间数据索引可以用于地理数据存储。
按物理存储划分
- 聚集索引:索引的叶子节点记录着完整的数据记录
- 非聚集索引:索引的叶子节点未记录着完整的数据记录
按逻辑划分
- 普通索引:最基本的索引,它没有任何限制
- 唯一索引:索引列的值必须唯一,但允许有空值。如果是组合索引,则列值的组合必须唯一
- 主键索引:主键索引是一种特殊的唯一索引,不允许有空值。一般是在建表的时候同时创建主键索引
- 组合索引:在多个字段上创建的索引,只有在查询条件中使用了创建索引时的第一个字段,索引才会被使用。使用组合索引时遵循最左前缀集合
- 全文索引:主要用来查找文本中的关键字,而不是直接与索引中的值相比较
9.聚簇、非聚簇索引
聚集索引与非聚集索引的区别:叶子节点是否存放数据记录
InnoDB主键使用的是聚簇索引,二级索引使用非聚簇索引,MyISAM 的主键和二级索引都是非聚簇索引
非聚簇索引表:表数据和索引是分成两部分存储的,主键索引和二级索引存储上没有任何区别。使用的是B+树作为索引的存储结构,所有的节点都是索引,叶子节点存储的是索引+索引对应的记录的数据。
聚簇索引表:表数据是和主键一起存储的,主键索引的叶结点存储行数据(包含了主键值),二级索引的叶结点存储行的主键值。使用的是B+树作为索引的存储结构,非叶子节点都是索引关键字,但非叶子节点中的关键字中不存储对应记录的具体内容或内容地址。叶子节点上的数据是主键与具体记录(数据内容)
聚簇索引优点
- 取出指定范围内的数据时性能比用非聚簇索引好
- 通过聚簇索引查找目标数据时理论上比非聚簇索引要快,因为非聚簇索引定位到对应主键时还要多一次目标记录寻址,即多一次I/O。
- 使用覆盖索引扫描的查询可以直接使用页节点中的主键值。
聚簇索引缺点
- 插入速度严重依赖于插入顺序。按照主键的顺序插入是最快的方式,否则将会出现页分裂(如用UUID主键就容易出现分裂),严重影响性能。因此对于InnoDB表一般都会定义一个自增的ID列为主键
- 更新主键的代价很高,因为将会导致被更新的行移动。
- 采用聚簇索引插入新值比采用非聚簇索引插入新值的速度要慢很多。因为插入要保证主键不能重复,判断主键不能重复,聚簇索引的叶子节点除了带有主键还有记录值,记录的大小往往比主键要大的多。这样就会导致聚簇索引在判定新记录携带的主键是否重复时进行昂贵的I/O代价。
10.索引是否越多越好
索引并不是建立越多越好,索引也不是万能的,在有些情况下使用索引反而会让效率变低,如下
- 数据量小(如只有几百行数据)的表不需要建立索引,建立会增加额外的索引开销
- 数量重复度大(如性别字段只有F、M)的字段也不需要建立索引
- 数据变更需要维护索引,因此更多的索引意味着更多的维护成本
- 更多的索引意味着也需要更多空间(索引也是需要空间来存放的)
11.索引的选择性
索引的选择性:不重复的索引值和记录总数的比值。选择性的最大值为 1,此时每个记录都有唯一的索引与其对应。选择性越高,每个记录的区分度越高,查询效率也越高。
因此在使用组合索引的时候尽量让选择性高的索引列放前面
12.覆盖索引是什么
覆盖索引:索引包含所有需要查询的字段的值,即在SELECT
后的字段就是索引字段
优点
- 索引项通常比记录要小,只读取索引能大大减少数据访问量
- 索引都按值的大小顺序存储,相对于随机访问记录,需要更少的I/O
- 大多数据引擎能更好的缓存索引,比如MyISAM在内存中只缓存索引,而数据需要借助操作系统来缓存。使用覆盖索引则可以只访问索引可以不使用系统调用
- 覆盖索引对于InnoDB表尤其有用,因为InnoDB使用聚集索引组织数据,如果二级索引中包含查询所需的数据,就不再需要在聚集索引中查找了
判断一个SQL语句是否用上索引覆盖,可以用explain检验输出的extra列是否显示为using index,MySQL查询优化器在执行查询前会决定是否有索引覆盖查询
13.数据库的优化方法
优化可以从很多方面来讲:如SQL语句、有效索引、数据结构、系统配置、硬件
- SQL语句以及索引的优化是最关键的。首先要根据需求写出结构良好的SQL,然后根据SQL在表中建立有效索引。具体点比如覆盖索引、前缀字段采用选择性高的
- 根据数据库范式来进行表结构设计,设计要考虑如何让查询更加有效。比如选择可存下数据的最小的、简单的数据类型,为了提升操作效率也可以选择反范式设计。
- 系统配置的优化。MySQL数据库是基于文件的,如果打开的文件数达到一定的数量,无法打开之后就会进行频繁的IO操作。
- 硬件优化。更快的IO、更多的内存。一般来说内存越大,对于数据库的操作越好。但如果查询缓慢是因为数据库内部的一些锁引起的,那么硬件优化就没有什么意义。
14.数据库的分库分表
原则上能不分库尽量不分库,无法避免时或者已经有趋势显示需要分库分表,则使用分库分表。
- 数据库的吞吐量达到瓶颈。需要扩多个数据库实例来提高
- 数据表的数据达到一定的量级时对查询等性能有明显的影响。可以通过分库分表来提升性能,有资料显示Mysql数据库单表数据量超过5000w后对查询性能有影响
- 为了避免后期复杂的扩容。进行提前规划,防范于未然。
- 水平切分:将同一个表中的记录拆分到多个结构相同的表中。 当一个表的数据不断增多时,水平切分可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。
- 垂直切分:将一张表按列切分成多个表。通常是按照列的关系密集程度进行切分,也可以利用垂直切分将经常被使用的列和不经常被使用的列切分到不同的表中。在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不同的库中
切分策略:
- 哈希取模:hash(key) % N
- 范围:可以是 主键ID 范围也可以是时间日期范围
- 映射表:使用单独的一个数据库来存储映射关系
分库分表存在的问题
- 数据切分后,分散在不同的数据库中,在使用数据库原生的数据库连接操作时,存在跨库连接,性能较差。
- 引入分布式事务,分布式事务的一致性很难解决。
- 分页,越往后翻页,查询越慢
- 不停机扩容难度增大
15.MVCC实现可重复读
InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列来实现的。这两个列一个保存了行的创建时间,一个保存行的过期时间(或删除时间)。 当然存储的并不是实际的时间值,而是系统版本号。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。在事务的可重复读隔离级别下,MVCC具体操作如下
- SELECT:InnoDB会根据以下两个条件检查每行记录:
- InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
- 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
- 只有符合.上述两个条件的记录,才能返回作为查询结果。
- INSERT:InnoDB为新插入的每一行保存当前系统版本号作为行版本号
- DELETE:InnoDB为删除的每一行保存当前系统版本号作为行删除标识
- UPDATE:InnoDB为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统
版本号到原来的行作为行删除标识。
通过保存两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。
MVCC只在REPEATABLE READ 和READ COMMITTED 两个隔离级别下工作。其他两个隔离级别都和MVCC不兼容,因为READ UNCOMMITTED总是读取最新的数据行,而不是符合TE当前事务版本的数据行。而SERIALIZABLE则会对所有读取的行都加锁
16.左连接 、右连接、内连接、全外连接
- left join左连接:返回包括左表中的所有记录和右表中连接字段相等的记录(没有对应的则显示为null)
- right join右连接:返回包括右表中的所有记录和左表中连接字段相等的记录(没有对应的则显示为null)
- inner join内连接:只返回两个表中连接字段相等的行
- full join全外连接:返回左右表中所有的记录和左右表中连接字段相等的记录。
17.MySQL有哪些锁
按锁粒度分类
- 行级锁
- 描述:行级锁是MySQL中锁定粒度最细的一种锁。表示只针对当前操作的数据行进行加锁。行级锁能大大减少数据库操作的冲突,其粒度最小,但开销也最大。行级锁分为共享锁和排他锁
- 特点:开销大,加锁慢,会出现死锁。发生锁冲突的概率最低,并发度也最高。
表级锁
- 描述:表级锁是MySQL中锁定粒度最大的一种锁。表示对当前操作的整张表加锁,其实现简单,开销较小,能被大部分MySQL引擎支持,最常使用的MyISAM与InnoDB都支持表级锁定。表级锁也分为共享锁与排他锁
- 特点:开销小,加锁快,不会出现死锁。发生锁冲突的概率最高,并发度也最低。
页级锁
- 描述:页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁开销小但冲突多,行级冲突少但开销大。因此采取了折衷的页级锁会一次锁定相邻的一组记录。BDB支持页级锁。
- 特点:开销和加锁时间介于表锁和行锁之间,也会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
按锁级别分类
共享锁
- 描述:共享锁又称读锁,是读取数据操作时创建的锁。获取共享锁的事务可以并发读取数据,但不能对数据进行修改,直到已释放所有共享锁
- 用法:
SELECT … LOCK IN SHARE MODE;
在查询语句后面增加LOCK IN SHARE MODE
,MySQL 就会对查询结果中的每行都加共享锁,当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请共享锁,否则会被阻塞。其他线程也可以读取使用了共享锁的表,而且这些线程读取的是同一个版本的数据。
排他锁
- 描述:排他锁又称写锁、独占锁,如果事务T对数据A加上排他锁后,则其他事务不能再对A加任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。
- 用法:
SELECT … FOR UPDATE;
在查询语句后面增加FOR UPDATE,MySQL 就会对查询结果中的每行都加排他锁,当没有其他线程对查询结果集中的任何一行使用排他锁时,可以成功申请排他锁,否则会被阻塞。
意向锁
描述:意向锁是表级锁,其设计目的主要是为了在一个事务中揭示下一行将要被请求锁的类型。InnoDB 中的两个表锁:
- 意向共享锁(IS):表示事务准备给数据行加入共享锁,也就是说一个数据行加共享锁前必须先取得该表的IS锁;
- 意向排他锁(IX):表示事务准备给数据行加入排他锁,说明事务在一个数据行加排他锁前必须先取得该表的IX锁。
意向锁是 InnoDB 自动加的,不需要用户干预。对于INSERT、UPDATE和DELETE,InnoDB 会自动给涉及的数据加排他锁;对于一般的SELECT语句,InnoDB 不会加任何锁,事务可以通过以下语句显式加共享锁或排他锁。
共享锁:
SELECT … LOCK IN SHARE MODE;
排他锁:
SELECT … FOR UPDATE;
18.乐观锁和悲观锁
- 乐观锁:顾名思义就是在操作数据时乐观,认为操作不会产生并发问题(不会有其他线程对数据进行修改),因此不会上锁。但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS(compare and swap)算法实现。
- 版本号机制:取出数据时获取当前版本号,更新时判断当前版本号是否是否是之前取出的版本号,如果不对应就更新失败
- CAS算法:乐观锁的另一种实现技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS操作中包含三个操作数 :需要读写的内存位置V、进行比较的预期原值A、拟写入的新值B。如果内存位置
V
的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B
。否则不做任何操作
- 悲观锁:总是假设最坏的情况,认为每次取数据时都认为其他线程会修改,所以都会加(悲观)锁。一旦加锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放。悲观锁的应用
- MySQL的表锁、行锁
- Java的
synchronized
关键字
通常在读数据操作的多的情况下用乐观锁,写数据操作多的情况下用悲观锁
19.JDBC连接过程
执行一次JDBC连接,需要进行如下步骤:
- 导入包:在程序中包含数据库编程所需的JDBC类。
- 注册JDBC驱动程序:需要初始化驱动程序,这样就可以打开与数据库的通信。
- 打开连接:使用用户名、密码、连接数据库参数,使用DriverManager.getConnection()方法来创建一个Connection对象,它代表一个数据库的物理连接。
- 执行SQL语句:使用Statement对象提交SQL语句到数据库执行。
- 从结果集中提取数据:使用ResultSet.getXXX()方法来检索的数据结果
- 清理环境资源:在JDBC操作数据库数据结束后,应该明确地关闭所有的数据库资源以减少资源的浪费。
20.数据库与缓存数据一致性问题
常见的三种解决方案
- 先更新数据库,再更新缓存
- 场景:适合更新数据与读取缓存分离,通过后台管理系统修改一些不经常修改的数据。各服务只负责读取缓存。
- 存在问题:比如线程A更新数据库,线程B更新数据库,线程B更新缓存,线程A更新缓存,这样最终存入的就是脏数据,缓存和数据库数据出现不一致。又或者数据库更新成功,缓存更新失败,缓存和数据库数据出现不一致
- 解决办法:
- 加分布式锁:操作串行化,因为更新场景很少,数据只读,不会影响性能。
- 返回前端页面失败,让前端重试,两次失败概率很小
- 通过MQ保证数据的最终一致性
- 先删除缓存,再更新数据库
- 场景:这种方案实际使用较多,适用于大部分能容忍脏数据的业务,及时出现脏数据,缓存过期后,也会读取最新的值
- 存在问题:可能出现脏数据,比如线程A删除缓存、更新数据库,线程B查询缓存不存在数据,并在A更新前从数据库获取旧数据,并先更新缓存。等现在A更新数据库后,缓存和数据库数据出现不一致
- 解决办法:
- 延时删除:首先进行第一次删除,在更新数据库后进行第二次删除
- 异步删除:通过消息队列的延时消息实现异步删除
- 先更新数据库,再删除缓存
- 场景:常用方案
- 存在问题:线程A查询数据库得到数据,线程B更新数据库、删除缓存,线程A将查到的旧数据写入缓存,最终导致缓存和数据库数据出现不一致
- 解决办法:异步延时删除
21.如何避免回表查询
回表查询:先定位主键值,再定位行记录,它的性能较扫一遍索引树更低
避免回表查询实际上就是实现覆盖索引,因为回表查询需要查询两次索引树,而使用覆盖索引后只需要查询一次索引树,具体做法就是为原本是回表查询的字段添加上联合索引
22.DDL和DML的区别
- DML:Data Manipulation Language,数据操纵语言。用于对数据库中的数据进行一些简单操作,如INSERT、DELETE、UPDATE、SELECT等.
- DDL:Data Definition Language,数据定义语言。用于管理对数据库中的某些对象(如DATABASE、TABLE),管理语句如CREATE、ALTER、DROP
区别
- DML操作是可以手动控制事务的开启、提交和回滚的。
- DDL操作是隐性提交的,不能实现事务回滚
23.索引的优化原则
最左前缀匹配原则。MySQL会从左向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如
a = 1 and b = 2 and c > 3 and d = 4
如果建立(a,b,c,d)
顺序的索引,d
是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,前面a,b,d的顺序可以任意调整;=
和in
可以乱序。比如a = 1 and b = 2 and c = 3
建立(a,b,c)
索引可以任意顺序,MySQL的查询优化器会优化成索引可以识别的形式尽量选择区分度高的列作为索引。区分度的公式是
count(distinct col)/count(*)
,表示字段不重复的比例,比例越大扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就近似于0索引列不能参与计算。比如
from_unixtime(create_time) = ’2014-05-29’
就不能使用到索引,原因很简单,B+树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成create_time = unix_timestamp(’2014-05-29’);尽量的扩展索引,不要新建索引。比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可。
24.解决慢查询步骤
慢SQL语句的可能原因
- 数据库CPU负载高。一般是查询语句中有很多计算逻辑,导致数据库cpu负载。
- IO负载高导致服务器卡住。一般和全表查询没索引有关系。
- 查询语句正常,索引正常但是还是慢。如果表面上索引正常,但是查询慢,需要查看索引是否生效。
- 并发时锁的阻塞。一般发生在高并发情况下,不会一直执行缓慢
解决慢查询步骤通常如下
- 打开慢日志查询,确定是否有SQL语句占用了过多资源,如果是,在不改变业务原意的前提下,对INSERT、GROUP BY、ORDER BY、JOIN等语句进行优化(如检查索引、添加索引等)。
- 考虑调整MySQL的系统参数: innodb_buffer_pool_size、innodb_log_file_size、table_cache等。
- 确定是否是因为高并发引起行锁的超时问题。
- 如果数据量过大,需要考虑进一步的分库分表
25.物理分页和逻辑分页的区别
- 概念:物理分页就是指数据库本身提供了分页方式,从数据库返回的就是分页结果,如MySQL的
limit
,oracle的rownum
;逻辑分页则是从数据库返回的所有结果,通过上层的代码进行分页 - 统一性:物理分页由于不同数据库有不同的分页关键字,所以不好统一。而逻辑分页是在代码层面上进行分页,对不同数据库都可以统一处理
- 框架应用:Hibernate采用的是物理分页;MyBatis使用RowBounds实现逻辑分页(针对ResultSet结果集执行的内存分页),也可以使用PageHelper插件则可以实现物理分页效果。
- 选型:物理分页的实时性强,适合数据库量大、更新频繁的场合;逻辑分页适合数据量小、不经常变更的场合(数据量过大可能会造成内存溢出)。物理分页总是优于逻辑分页,因为没有必要将本该属于数据库端的压力加到应用端
26.MySQL主从复制
概述
MySQL 主从复制是指数据可以从一个MySQL数据库服务器(主节点)复制到一个或多个从节点。MySQL 默认采用异步复制方式,这样从节点不用一直访问主服务器来更新自己的数据,数据的更新可以在远程连接上进行,从节点可以复制主数据库中的所有数据库/特定的数据库/特定的表
原因
- 提高数据库读写性能,提升系统吞吐量:在业务复杂的系统中,如果有一条SQL语句的执行需要锁表,导致 MySQL暂时不能提供读的服务,就会影响到运行中的业务,采用主从复制,让主库负责写,从库负责读,这样即使主库出现了锁表的情景,通过读从库也可以保证业务的正常运作。
- 架构扩展需要:业务量越来越大,I/O 访问频率过高,单机无法满足,此时需要做多数据库的存储来降低磁盘I/O 访问的频率,提升整个数据库性能
- 做数据库热备
原理
- 当主节点上数据发生改变时,记录到binlog二进制日志中
- 从节点会每隔一段时间对主节点的二进制日志进行检测其是否发生改变,如果发生改变则开启一个 I/O线程去请求master 二进制事件
- 主节点会为每个从节点的 I/O 线程启动一个dump线程,用于向其发送二进制事件,I/O线程会把发送过来的事件保存到从节点本地的relaylog延迟日志中
- 这时从节点会启动一个SQL线程读取延迟日志,并从中解析得到SQL语句逐一执行,使得从节点的数据和主节点保持一致
- 最后I/O线程和SQL线程都将进入睡眠状态,等待下一次被唤醒。
注意事项
- 主节点将操作语句记录到 binlog日志中,然后授予从节点远程连接的权限,要注意主节点一定要开启 binlog二进制日志功能,通常为了数据安全考虑,从节点也应开启binlog功能
- MySQL主从复制至少需要两个MySQL服务,服务可以部署在不同的服务器上,也可以在同一台服务器上
- MySQL主从复制最好确保主节点和从节点的MySQL版本相同(如果不能满足版本一致,那至少要保证主节点的版本低于从节点的版本)
27.MySQL日志有哪些
日志是MySQL数据库的重要组成部分。日志文件中记录着MySQL数据库运行期间发生的变化,包括数据库客户端的连接状况、SQL语句的执行情况、出现错误等等信息。MySQL中包含如下几种日志
错误日志:错误日志默认开启且无法被禁止。错误日志存储在MySQL安装目录的data文件夹中通常名称为
hostname.err
,hostname
代指服务器主机名。错误日志用于记录如下信息- 服务器启动和关闭过程中的信息(不一定是错误信息)
- 服务器运行过程中产生的错误信息
- 事件调度器运行一个事件时产生的信息
- 主从复制架构中在从服务器上启动服务器进程时产生的信息
查询日志:查询日志是默认关闭的。查询日志用于记录用户的所有操作,包含查询、修改、更新等操作信息,在并发操作大的环境下使用查询日志会产生大量信息从而导致不必要的磁盘I/O,影响MySQL性能。所以如果不是以调试数据库为目建议不要开启查询日志。
慢查询日志:慢查询日志用于记录执行时间超过指定时间的查询语句。慢查询日志存储在MySQL安装目录的data文件夹中通常名称为
hostname-slow.log
。通过慢查询日志可以查找出哪些语句执行效率很低,以便对其进行优化。一般建议开启,它对服务器性能的影响微乎其微,但对于定位数据库的性能问题有很大帮助事务日志:事务日志用于记录数据的修改行为,保证事务特性。InnoDB存储引擎在修改数据时先修改其内存拷贝,再把修改行为记录到硬盘上的事务日志中,而不用每次都将修改的数据本身持久到磁盘,等事务日志持久化后,再慢慢将内存中的修改数据在后台持久到磁盘。目前大多数的存储引擎也都是这样实现的,通常称之为预写式日志,修改数据需要写两次磁盘。如果数据的修改行为已经记录到事务日志并持久化,但修改数据本身还没有写回磁盘,此时系统崩溃,存储引擎在重启时能够自动恢复这部分修改的数据。具体恢复方式视存储引擎而定。
二进制日志:二进制日志用于以二进制格式来记录数据库所有变化的操作,实现数据恢复、主从复制功能。
中继日志:中继日志用于记录主节点数据库发送过来的二进制日志文件,实现主从复制功能。从节点会开启一个SQL线程去读取解析中继日志中的SQL语句并执行,从而使主从节点的数据保持一致
28.反范式设计
范式:在设计关系数据库时需要遵从的规范要求。为了设计出合理的关系型数据库往往需要参照范式,各种范式呈递次规范(如第二范式基于第一范式),越高的范式数据库冗余越小。但有时一昧追求范式减少冗余,反而会降低数据读写的效率,这个时候就要采用反范式,利用空间来换时间。
反范式:不满足范式的模型,就是反范式模型。反范式跟范式所要求的正好相反,在反范式的设计模式中,可以允许适当的数据冗余,用这个冗余可以缩短获取数据的时间。没有冗余的数据库未必是最好的数据库,有时为了提高运行效率,就必须降低范式标准,适当保留冗余数据。
具体做法: 在概念数据模型设计时遵守第三范式,将降低范式标准的工作放到物理数据模型设计时考虑。降低范式就是增加字段,减少查询关联,提高查询效率,因为在数据库的操作中查询的比例要远远大于其他操作的比例。但是反范式化一定要适度,并且在原本已满足三范式的基础上再做调整的。
优点:减少表的关联查询连接,可以更好的利用索引筛选和排序,提高查询性能
缺点:造成数据的冗余存储
29.char
、varchar
异同
在MySQL数据库中,char
和varchar
都是常用的字符型数据类型
相同点
char(n)
、varchar(n)
中的n都代表字符的个数。- 超过
char
、varchar
最大长度n的限制后,字符串会被截断
不同点
- 空间占用:
char
不论实际存储的字符数都会占用n个字符的空间(如果实际值不够会用空格字符补足),而varchar
只会占用实际字符应该占用的字节空间加1或加2(额外空间用于保存长度) - 最大空间上限:
char
的存储上限为255(2^8-1)字节,varchar
的存储上限是65535(2^16-1)字节 - 空格符处理:char在存储时会截断尾部的空格,而varchar不会截断尾部空格
适用场景
- 短字符串: 短字符串应当选用char,极大节省了存储空间
- 定长字符串:固定长度的字符串也选用char,如果用varchar则还需要额外存储长度占据容量
- 频繁改变的字符串:频繁改变的字符串也选用char,因为varchar每次改变后存储都会有额外的计算(计算字符串长度),而char不需要
- 其余情况:选择varchar能够尽可能地减少空间占用
简单来看,节省空间选varchar,提升效率选char合适
Java基础
1.手写Java动态代理
首先声明一个接口
1 | public interface UserService { |
在定义一个接口实现类
1 | public class UserServiceImpl implements UserService { |
最后写一个动态代理类,动态代理的代理体现在对代理对象方法的增强,如这里在代理对象userService
的所有方法前后加上两个打印语句。该代理类还该定义了getProxy()
方法来返回代理对象,在调用
1 | public class UserServiceProxy implements InvocationHandler { |
2.Integer的缓存池问题
valueOf()
:先判断值是否在缓存池中,如果在的话就直接返回缓存池的内容,否则返回新的Integer
对象
new Integer()
:直接创建新的Integer
对象
按照上面两个方法的不同实现,可以得知
1 | Integer a = new Integer(127); |
编译器会在Integer
的自动装箱过程调用valueOf()
方法,因此多个值相同且值在缓存池范围内的Integer
实例(上面的c和d)使用自动装箱创建时会引用相同的对象
各基本类型对应的缓冲池如下:
boolean
:true 、falsebyte
:allshort
:-128 ~ 127int
: -128 ~ 127char
: \u0000 ~ \u007F
3.final
关键字作用
- 修饰数据:声明数据为常量,可以是编译时常量,也可以是在运行时被初始化后不能被改变的常量。
- 对于基本类型,
final
使数值不变 - 对于引用类型,
final
使引用不变,即不能引用其它对象
- 对于基本类型,
- 修饰方法:声明方法不能被子类重写
- 修饰类:声明类不允许被继承
4.Java反射
每个类都有一个 Class 对象,包含了与类有关的信息。当编译一个新类时,会产生一个同名的 .class 文件,该文件内容保存着 Class 对象。类加载相当于 Class 对象的加载,类在第一次使用时才动态加载到 JVM 中,也可以用 Class.forName("com.mysql.jdbc.Driver")
这种方式来控制类的加载,Class
和java.lang.reflect
一起对反射提供了支持,主要包含了以下三个类:
Field
:调用get()
和set()
方法来读取和修改Field
对象关联的字段Method
:通过invoke()
方法来调用与Method
对象关联的方法Constructor
:调用newInstance()
创建新的对象
优点
- 可扩展性 :应用程序可以利用全限定名创建可扩展对象的实例来使用来自外部的用户自定义类。
- 类浏览器和可视化开发环境 :一个类浏览器需要可以枚举类的成员。可视化开发环境(如 IDE)可以从利用反射中可用的类型信息中受益,以帮助程序员编写正确的代码。
- 调试器和测试工具 : 调试器需要能够检查一个类里的私有成员。测试工具可以利用反射来自动地调用类里定义的可被发现的 API 定义,以确保一组测试中有较高的代码覆盖率。
缺点
- 性能开销 :反射涉及了动态类型的解析,所以 JVM 无法对这些代码进行优化。因此反射操作的效率要比非反射操作低。应该避免在经常被执行的代码或对性能要求很高的程序中使用反射。
- 安全限制 :使用反射技术要求程序必须在一个没有安全限制的环境中运行
- 内部暴露 :反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),使用反射可能导致代码功能失调并破坏可移植性、抽象性
5.Java重写、重载要求
重写
- 存在于继承体系,由子类实现与父类方法签名(方法名称和参数列表)相同的一个方法
- 父类的私有方法、
static
修饰的方法不能被重写 - 子类方法的访问权限必须大于等于父类方法,如子类
public
,父类protected
- 子类方法的返回类型必须是父类方法返回类型或为其子类型, 如子类
ArrayList
,父类List
- 子类方法抛出的异常类型必须是父类抛出异常类型或为其子类型,如子类
IOException
,父类Exception
重载
- 存在于同一个类中,实现方法的方法名和一个已存在的方法方法名相同
- 参数类型、个数、顺序要保证至少有一个不同
- 返回值不同,其它都相同不能算是重载,如
double fun()
和int fun()
6.transient
关键字
只要一个类实现了Serilizable
接口就可以被序列化,这个类的所有属性和方法都会自动序列化。然而在实际开发过程中,对于类的一些敏感信息(如密码,银行卡号等),不希望在网络操作(主要涉及到序列化操作,本地序列化缓存也适用)中被传输,这些信息对应的变量就可以加上transient
关键字。简单来说,这个字段的生命周期仅存于调用者的内存中而不会写到磁盘里持久化。
使用要点
- 一旦变量被
transient
修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法被访问 transient
关键字只能修饰变量,而不能修饰方法和类。变量如果是用户自定义类的变量,则该类需要实现Serializable
接口- 静态变量不管是否被
transient
修饰,均不能被序列化
ArrayList 中存储数据的数组 elementData就是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。
7.反射创建对象的方法
通过反射创建对象,第一步需要获取对象对应的Class,获取方法有三种
Class.forName()
:调用Class类的静态方法,需要明确类的全路径名。.class
:通过类来获取,需要在编译前明确要操作的 ClassgetClass()
:调用类对象的方法,需要有对象实例
得到Class后可以创建对象实例
newInstance()
:通过无参构造器创建对象实例getConstructor()
:先获得有参构造器,再创建对象实例
8.常用的字符编码
字符编码就是把字符转换为字节,而解码是把字节重新组合成字符。如果编码和解码过程使用不同的编码方式那么就出现了乱码。
- GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节;
- UTF-8 编码中,中文字符占 3 个字节,英文字符占 1 个字节;
- UTF-16be 编码中,中文字符和英文字符都占 2 个字节。UTF-16be 中的 be 指的是 Big Endian,也就是大端。相应地也有 UTF-16le,le 指的是 Little Endian,也就是小端。
Java 的内存编码使用双字节编码 UTF-16be,这不是指 Java 只支持这一种编码方式,而是说 char 这种类型使用 UTF-16be 进行编码。char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储。
9.String的hashcode()
String类型重写了hashcode()方法,其哈希码的计算方式为:从左到右遍历字符串对应的字符数组,用原来的hashcode(默认为0)乘以31后再加上这个字符的ASCII码得到新的hashcode,选择31作为乘数的原因如下
- 31相当于二进制的11111可以被JVM优化,乘法可以被移位和减法运算取代,h*31可转换成h<<5
- 31是素数,与素数相乘得到的结果比其他方式更容易产生唯一性,虽然也有其他素数,但选用31是因为31作为乘数得到的哈希值分布比较平均,不易出现哈希冲突
10.Java原子操作有哪些
原子操作:不可中断的一个或一系列操作 。在确认一个操作是原子的情况下,多线程环境里面,可以避免仅仅为保护这个原子操作在外围加上性能昂贵的锁。 Java中的原子操作如下
- 除long和double之外的基本数据类型的赋值操作
- 引用对象的赋值操作
- juc包中原子类的相关
i++不是原子操作,是如下3个原子操作的组合,如果线程1运行到第2步后tmp值为1但还为赋值,此时CPU调度切换到线程2运行完3步赋值给i值为1,再切换回线程1继续执行第3步,tmp值被赋值为1,结果就是两个线程执行完i的值只增加1,即非原子性操作
- 读取主存中的count值,赋值给一个临时变量tmp
- tmp+1
- 将tmp赋值给i
11.为什么long和double的操作没有原子性
在Java中long和double都8个字节共64位,那么如果是一个32位的系统,读写long或double的变量时,如果是32位的系统就需要读完一个64位的变量,需要分两步执行,每次读取32位,这样就对double和long变量的赋值就会出现问题:如果有两个线程同时写一个变量内存,一个进程写低32位,而另一个写高32位,这样将导致获取的64位数据是失效的数据。
解决方法
- 使用volatile修饰long和double,那么其读写都是原子操作
- 使用64位系统可以以一次性读写long或double,那么其读写也都是原子操作
12.静态内部类和普通内部类的区别
静态类:使用static
关键字修饰的类
- public static class和public class的区别:在java中,普通的顶级类是不能使用static关键字修饰的。只有内部类可以使用static修饰。
- 普通的内部类和static内部类区别:
- 静态内部类(static修饰的内部类)没有对外部类的引用,所以静态内部类只能访问外部类的静态属性或方法。并且在初始化的时候可以单独存在,
- 普通内部类有对外部类的引用,所以普通内部类不能独立存在,初始化的时候必须通过外部类的实例实现。并且普通内部类可以直接访问外部类的普通属性和函数(包括私有的属性和函数)同时也能访问外部类的静态属性和函数。
13.JDK8的特性
- 接口的默认方法:默认方法就是接口可以有实现方法,而且不需要实现类去实现其方法。只需在方法名前面加个 default 关键字即可实现默认方法。
- Lambda 表达式 :Lambda允许把函数作为一个方法的参数(函数作为参数传递到方法中),从而使代码变的更加简洁紧凑。Lambda 表达式主要用来定义行内执行的方法类型接口,其免去了使用匿名方法的麻烦,并且给予Java简单但是强大的函数化的编程能力。
- 方法引用:通过方法的名字来指向一个方法。方法引用可以使语言的构造更紧凑简洁,减少冗余代码。方法引用使用一对冒号 :: ,一般结合forReach()来用
- Optional 类 :Optional 类已经成为 Java 8 类库的一部分,用来解决空指针异常。Optional 类是一个可以为null的容器对象。如果值存在则isPresent()方法会返回true,调用get()方法会返回该对象,调用orElse()方法当无值时会返回默认值。
- …
14.finally与return的返回结果问题
1 | public int testFinally(){ |
该方法中在try-catch中修改局部变量count,结果将会返回5,如果将移除finally中的return语句,取消最后一行注释返回结果则是2,这个结果与finally语句的特性有关
- 无论是否出现异常,finally块中代码都会执行(不过要建立在try语句块执行的基础上)
- 无论try、catch语句中是否存在return语句,finally块中代码也会执行
- 在finally语句中操作基本数据类型不影响前面return返回结果,除非语句中还有return语句(如上示例),在finally语句中修改引用类型时没有return语句也会影响引用类型的返回结果
在JVM虚拟机中有虚拟机栈,每个方法都对应一个栈帧,栈帧主要由局部变量表和操作数栈组成,在前面代码中,变量count需要放到局部变量表,每次关于该变量的运算都要把count从局部变量表拷贝出一份放到操作数栈中进行计算,得到结果后通过return语句写回局部变量表。所以finally语句中没有return语句就不会更新局部变量表的值,有则会更新局部变量表的值。
15.BIO、NIO、AIO概述
BIO:Blocking IO,同步阻塞IO,是JDK1.4之前的传统IO模型。 线程发起IO请求后将会一直阻塞IO,直到缓冲区数据就绪后,再进入下一步操作。针对网络通信都是一请求一应答的方式,虽然简化了上层的应用开发,但在性能和可靠性方面存在着巨大瓶颈,因为每个I/O请求都需要新建一个线程来专门处理,那么在高并发的场景下,机器资源很快就会被耗尽。
NIO:Non-Blocking IO,同步非阻塞IO。线程发起IO请求后会立即返回。同步指的是必须等待IO缓冲区内的数据就绪,而非阻塞指的是,用户线程不原地等待IO缓冲区,可以先做一些其他操作,但是要定时轮询检查IO缓冲区数据是否就绪。Java中的NIO 是new IO的意思,其实是NIO加上IO多路复用技术。普通的NIO是线程轮询查看一个IO缓冲区是否就绪,而Java中的new IO指的是线程轮询地去查看一堆IO缓冲区中哪些就绪,这是一种IO多路复用的思想。IO多路复用模型中,将检查IO数据是否就绪的任务,交给系统级别的select或epoll模型,由系统进行监控,减轻用户线程负担。
NIO主要有buffer、channel、selector三种技术的整合,通过零拷贝的buffer取得数据,每一个客户端通过channel在selector(多路复用器)上进行注册。服务端通过selector不断轮询channel来获取客户端的信息。channel上有connect,accept(阻塞)、read(可读)、write(可写)四种状态标识。根据标识来进行后续操作。所以一个服务端可接收无限多的channel,不需要新开一个线程。大大提升了性能。
AIO:Asynchronous IO,异步非阻塞IO。 上述NIO实现中,需要用户线程定时轮询,去检查IO缓冲区数据是否就绪,占用应用程序线程资源,其实轮询相当于还是阻塞的,并非真正解放当前线程,因为它还是需要去查询哪些IO就绪。而真正的理想的异步非阻塞IO应该让内核系统完成,用户线程只需要告诉内核,当缓冲区就绪后,通知我或者执行我交给你的回调函数。AIO可以做到真正的异步的操作,但实现起来比较复杂,支持纯异步IO的操作系统非常少,目前也就windows是IOCP技术实现了,而在Linux上,底层还是是使用的epoll实现的。
16.Object
的方法有哪些
Object
是java所有类的基类,是整个类继承体系的顶端,也是最抽象的一个类,子类继承其的方法如下
getClass()
:返回当前对象的类hashCode()
:返回当前对象的哈希码,值的整数范围在2^31到2^31 - 1equals(Object obj)
:用于比较当前对象与目标对象是否相等,默认是比较引用是否指向同一对象clone()
:返回当前对象的一个副本toString()
:返回对象的字符串表示,默认是返回当前类的全限定性类名+@+十六进制hashCodewait()、wait(long)、wait(long,int)
: 这三个方法都是用于线程间通信,作用是阻塞当前线程,等待其他线程调用notify()
或notifyAll()
方法将其唤醒。这些方法都是public final的,不可被重写。notify()
、notifyAll()
:notify()
随机唤醒之前在当前对象上调用wait()
的一个线程,notifyAll()
唤醒所有之前在当前对象上调用wait()
的线程
17.JAVA执行HelloWorld程序的全过程
1 | public class HelloWorld { |
执行上述这样一个HelloWorld程序时在内部的执行流程如下
- 执行
HelloWorld.java
文件,生成HelloWorld.class
字节码文件 - 虚拟机执行
HelloWorld.class
,将这个类加载到内存中(即方法区的类代码区中) - 虚拟机通过类找到
HelloWorld
的主方法(程序的入口方法),访问权限为public
(公有可用),虚拟机传递String[]
类型参数的地址到主方法的args
中去,并在栈区为args开辟内存空间 String s
在栈区开辟空间,定义一个String
类型的局部变量s
,在第一行语句中s
值还不确定s = "Hello World"
定义了一个”Hello World”常量对象存放在方法区的常量值数据区中,并且会创建相应的toString()
方法,令栈区的变量s
指向这个常量。System.out.println(s)
虚拟机找到标准类库中的System.class
类并加载到内存中(方法区),System.out
为标准字节输出流对象,并调用println()
方法将变量s的值打印到屏幕上。
18.Java中的守护线程
在Java中的线程分为用户线程(User Thread)和守护线程(Daemon Thread),守护线程是用于守护用户线程的,只要当前JVM实例中存在任何一个非守护线程没有结束,守护线程就继续工作,直到最后一个非守护线程结束,守护线程会随着JVM一同结束工作。守护线程用于为其他线程的运行提供便利服务,最典型的应用就是垃圾回收线程。守护线程并非只有虚拟机内部提供,用户在编写程序时也可以自己通过调用线程的setDaemon(true)
方法来设置守护线程。使用守护线程时要注意的问题
setDaemon(true)
必须放在thread.start()
前设置,否则会抛出IllegalThreadStateException
异常,因为不能将已经运行的线程设置为守护线程- 在守护线程中产生的新线程也是守护线程
- 不要是所有功能都可以能分配给守护线程来进行服务,比如读写操作或者计算逻辑,因为一旦所有用户线程结束,守护线程也将结束,这时候如果交给守护线程的计算或读写任务还没完成,就会出现造成严重问题
传统意义上的守护进程则是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件,即守护线程不依赖于终端而依赖于系统,与系统“同生共死”。但是Java应用层面上的守护线程弱化了很多,除了垃圾回收线程比较重要且常见外,用于内存资源或者线程的管理(但是非守护线程也可以做),或者在守护线程中直接退出当前JVM即实现JVM自动退出的功能,实际应用也很少。
19.JAVA如何读取大文件
一般在读取文件时会将文件数据直接全部读取到内存,再进行操作,这种做法对于小文件没有问题,但对于大文件来说就会抛出OOM异常,所以在读取大文件时需要把大文件分成多个子区域分多次读取
- 思路一:通过文件流边读边用。在while循环中使用文件流的
read()
方法每次读取一定长度的数据到内存,然后对这段数据进行处理后,再重新读取下一段数据,依次循环。 - 思路二:通过通道边读边用。对大文件建立NIO管道
FileChannel
,每次调用管道的read()
会先将数据读取到已分配固定长度的ByteBuffer
,然后对缓冲区上的数据进行处理后,继续读取下一段数据。理论上这种NIO通道的方法比传统文件流的读取速度要快一些 - 思路三:内存文件映射。对大文件建立NIO管道
FileChannel
,借助MappedByteBuffer
直接将文件内容映射到虚拟内存的一块区域,然后直接操作这块内存当中的数据而无需每次都通过I/O去物理硬盘读取文件,这种方式可以更大程度地提高读取速度 - 思路四:分块读取。使用
RandomAccessFile
的seek()
方法进行分块读取,处理数据后继续下一块读取
20.Java序列化ID的作用
在实现Serializable
接口的类中往往会看到一个SerialVersionUID变量(序列化ID)。这个序列化ID是很关键的,它决定了反序列化是否能成功。Java的序列化机制是通过在运行时判断类的SerialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把字节流中的SerialVersionUID与实体类的SerialVersionUID进行比较,如果相同则认为存储数据的类是一致的,就可以进行反序列化,否则会报出序列化版本不一致的异常
存在问题:在创建实现Serializable
接口的类时如果没有显式声明SerialVersionUID遍历,则Java序列化机制会根据编译时的class来自动生成一个SerialVersionUID作为序列化版本,这种情况下只有同一次编译生成的class才会生成相同的SerialVersionUID。比如在编写一个类时,随着需求变更,需要在该类中添加新的字段,这时就需要重新编译并且会生成新的SerialVersionUID,此时再用之前序列化得到的文件(字节流)反序列化回来会发现SerialVersionUID不一致,从而抛出序列化版本不同异常,导致反序列化失败。
解决办法:在类中手动设置一个long类型的SerialVersionUID变量,要求其值保持不变,这样在发生类的变更后仍可以进行序列化和反序列化。
21.static
关键字
在Java中通常需要用new
关键字来创建类对象,数据存储空间才会被分配,方法才能被外界调用。但有时只想为特定成员变量分配单一存储空间或是想直接调用方法,而不用创建对象,这时候就可以使用static
关键字,其用法如下
- 修饰变量:
static
静态变量只依赖于类存在(通过类即可访问),只分配一份内存,不与任何对象实例关联 - 修饰方法:
static
静态方法无须用实例来引用,可以通过类名.静态方法名
来直接引用,且静态方法中只能使用静态变量,不能使用非静态变量和方法 - 修饰内部类:静态内部类是指用
static
修饰的内部类,静态内部类实例时不需要外部类的对象实例,可以创建静态方法和非静态方法,静态的方法在在外层需要通过静态内部类调用,非静态方法则需要通过静态内部类对象调用(和普通类相似)。在类中只能引用外部类的static
成员变量 - 修饰代码块:在类加载时
static
静态代码块会被调用,且只执行一次
Java多线程
1.ThreadLocal
的用法和优缺点
用法
ThreadLocal
用于保存某个线程共享变量:对于同一个静态ThreadLocal
对象,不同线程可以从中get()
,set()
,remove()
自己的变量,而不会影响其他线程的变量。ThreadLocal
的常用方法如下
get()
: 获取ThreadLocal
中当前线程共享变量的值。set()
: 设置ThreadLocal
中当前线程共享变量的值。remove()
: 移除ThreadLocal
中当前线程共享变量的值。initialValue()
:ThreadLocal
没有被当前线程赋值时或当前线程刚调用remove()方法后调用get()方法,返回此方法值。
调用
ThreadLocal
的get()
方法时,实际上是从当前线程中获取ThreadLocalMap<ThreadLocal, Object>
,然后根据当前ThreadLocal
获取集合中当前线程的对应的Object
对象。set()
,remove()
实际上是同样的道理。
ThreadLocal
与synchonized
的区别
ThreadLocal
和Synchonized
都用于解决多线程并发访问。可是ThreadLocal
与synchronized
有本质的差别。synchronized
是利用锁机制,使变量或代码块在某一时该仅仅能被一个线程访问。而ThreadLocal
是为每个线程都提供变量副本,使每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。而synchronized
却正好相反,它用于在多个线程间通信时可以获得数据共享。
synchronized
用于线程间的数据共享,而ThreadLocal
则用于线程间的数据隔离。
优点
- 提供线程内的局部变量。每个线程都自己管理自己的局部变量互不影响
- 线程死去的时候,
ThreadLocalMap
中的线程共享变量会销毁。 ThreadLocalMap<ThreadLocal,Object>
键值对数量为ThreadLocal
的数量,一般ThreadLocal
数量很少,相比在ThreadLocal
中用Map<Thread, Object>
键值对存储线程共享变量(Thread
数量一般来说比ThreadLocal
数量多),性能提高很多。
缺点
ThreadLocalMap<ThreadLocal, Object>
的Entry
是继承WeakReference
的,存在弱引用问题,因此当线程没有结束ThreadLocal
就可能被垃圾回收器回收掉,又因为键值对中的value是强引用,则可能导致线程中存在ThreadLocalMap<null, Object>
的键值对,造内存泄露(ThreadLocal
被回收,ThreadLocal
关联的线程共享变量还存在)。
虽然ThreadLocal的get,set方法可以清除ThreadLocalMap中key为null的value,但是get,set方法在内存泄露后并不会必然调用,所以为了避免内存泄露的情况出现,一般在不需要使用ThreadLocal中的线程共享变量时,显式地调用
remove()
清除线程共享变量;
2.怎么解决高并发问题
- 采用HTML静态化页面
- 数据库采用集群方式并进行读写分离,提高数据读写能力;分库分表
- 采用缓存
- 服务降级、服务熔断
- 负载均衡,分流压低单个服务器压力
- 程序优化减少不必要的冗余代码
3.CAS操作
CAS,Compare And Swap,比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。CAS指令在执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
CAS虽然很高效的解决了原子操作问题,但仍然存在三大问题
- 循环时间长开销很大:执行自增方法时,如果CAS操作失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。
- 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
- ABA问题:如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,在这段期间它的值曾经被改成B,直到后来才被改回A,CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的ABA问题。
Java并发包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference
,它可以通过控制变量值的版本来保证CAS的正确性,在使用CAS方法时除了设置值还可以设置版本号。因此在使用CAS前要考虑清楚ABA问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
4. 自旋锁
自旋锁:是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成忙等待。
为了实现保护共享资源而提出的一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。
优点:自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
不可重入的自旋锁
1 | class SpinLock |
可重入的自旋锁(借助计数器实现)
1 | public class ReentrantSpinLock { |
5.CopyOnWriteArrayList
Copy-On-Write:当要对一块内存进行修改时,不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完后将指向原来内存指针指向新的内存,然后回收掉原来的内存。在迭代List集合时是不允许对这个List集合进行元素添加或者删除的写操作的,否则会抛出异常。而使用CopyOnWriteArrayList
则可以避免这个问题。
读写分离:在写操作通过Arrays.copyOf()
在一个复制的数组上进行,而读操作还是在原始数组中进行,读写分离,互不影响。写操作需要加锁(观察源码可以发现CopyOnWriteArrayList
在add()
和remove()
方法都添加了synchronize
关键字),防止并发写入时导致写入数据丢失。写操作结束之后需要把原始数组指向新的复制数组。
优点
- 解决了这种List集合遍历迭代时的写操作问题
- 在写操作的同时允许读操作,大大提高了读操作的性能
缺点
- 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右**
- 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中
CopyOnWriteArrayList
适合读多写少的应用场景,而不适合内存敏感以及对实时性要求很高的场景。
6.ReentrantLock
与synchronized
的区别
- 实现方式:
synchronized
是 JVM 实现的,而ReentrantLock
是 JDK 实现的。 - 性能:新版本 Java 对
synchronized
进行了很多优化,synchronized
的性能与ReentrantLock
差不多 - 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
ReentrantLock
可中断,而synchronized
不行 - 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
synchronized
中的锁是非公平的,ReentrantLock
默认情况下也是非公平的,但是也可以是公平的,可以通过构造函数来手动设置。 - 锁绑定多个条件:一个
ReentrantLock
可以同时绑定多个Condition
对象。
建议:除非需要使用ReentrantLock
的高级功能,否则优先使用synchronized
。因为synchronized
是 JVM 实现的一种锁机制,JVM 原生地支持它,而ReentrantLock
不是所有的 JDK 版本都支持。并且使用synchronized
不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。
公平锁:获取不到锁的时候,会自动加入队列,等待线程释放后,队列的第一个线程获取锁
非公平锁:获取不到锁的时候,会自动加入队列,等待线程释放锁后,所有等待的线程同时去竞争
可重入:同一个线程可以反复获取锁多次,然后需要释放多次
7.Java多线程方法
sleep()
:Thread.sleep(millisec)
方法会休眠当前正在执行的线程,millisec 单位为毫秒。sleep()
可能会抛出InterruptedException
,因为异常不能跨线程传播回 main() 中,因此必须在当前线程中进行处理。线程中抛出的其它异常也同样需要在本地进行处理。yield()
:Thread.yield()
表示当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。interrupt()
:中断线程,让线程提前结束。对于线程中的循环体可以以!interrupted()
作为循环条件,而调用 interrupt() 方法后会修改线程的中断标记,让循环结束join()
:在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。wait()、notify()、notifyAll()
:调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。await()、signal()、signalAll()
:juc包中提供了Condition
类来实现线程之间的协调,可以在Condition
上调用await()
方法使线程等待,其它线程调用signal()
或signalAll()
方法唤醒等待的线程。 相比于wait()
这种等待方式,await()
可以指定等待的条件,因此更加灵活。 使用Lock
来获取一个Condition
对象。
8.Java的线程状态
一个线程只能处于一种状态,这里线程状态特指在 Java 虚拟机的线程状态
- 新建(NEW):创建后尚未启动
- 可运行(RUNABLE):正在 Java 虚拟机中运行
- 阻塞(BLOCKED):请求获取 monitor lock 从而进入 synchronized 函数或者代码块,但是其它线程已经占用了该 monitor lock,所以处于阻塞状态。
- 无限期等待(WAITING):等待其它线程显式地唤醒。 阻塞和等待的区别在于阻塞是被动的,它是在等待获取 monitor lock,而等待是主动的
- 调用无超时参数的
wait()
方法进入该状态,调用notify()/notifyAll()
退出该状态 - 调用其他线程
join()
方法进入该状态,等被调用线程执行完毕退出该状态
- 调用无超时参数的
- 限期等待(TIMED_WAITING): 无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒
- 调用
Thread.sleep()
方法进入该状态,时间结束后退出该状态 - 调用有超时参数的
wait()
方法进入该状态,调用notify()/notifyAll()
或时间结束后退出该状态 - …..
- 调用
- 死亡(TERMINATED):可以是线程结束任务之后自己结束,或者产生了异常而结束
9.CyclicBarrier
、CountdownLatch
区别
CountDownLatch
:用于控制一个或者多个线程去等待多个线程。 其维护了一个计数器,每次调用 countDown()
方法会让计数器的值减 1,减到 0 的时候,因调用await()
方法而在等待的线程就会被唤醒。
CyclicBarrier
:用于控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。 和CountdownLatch
相似,都是通过维护计数器来实现的。线程执行await()
方法之后计数器会减 1,减到0的时候,因调用await()
方法而在等待的线程就会被唤醒
CyclicBarrier
和CountdownLatch
的区别
CyclicBarrier
的计数器通过调用reset()
方法可以循环使用,所以叫做循环屏障。CyclicBarrier
有两个构造函数,其中parties
指示计数器的初始值,barrierAction
在所有线程都到达屏障的时候会执行一次。CountdownLatch
适用于所有线程都通过某一点后通知方法,而CyclicBarrier
则适合让所有线程在同一点同时执行CountdownLatch
利用继承AQS的共享锁来进行线程的通知,利用CAS来进行,而CyclicBarrier
则利用ReentrantLock
的Condition
来阻塞和通知线程
10.shutdown()
、shutdownNow()
区别
shutdown()
:调用shutdown()
方法后,线程池会等待我们已经提交的任务执行完成,此时线程池不再接受新的任务,如果再向线程池中提交任务会抛出RejectedExecutionException
异常。如果线程池的shutdown()
方法已经调用过,重复调用没有影响。
shutdownNow()
:和shutdown()
方法一样,调用shutdownNow()
方法后,调用线程会立刻从该方法返回而不会阻塞等待。
区别:调用shutdown()
方法会等待线程都执行完毕之后再关闭,而调用 shutdownNow()
方法相当于调用每个线程的interrupt()
方法,线程池会通过调用worker线程的interrupt()
方法尽最大努力去终止运行中的任务。
11.Executors
创建的四种线程池类型
Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。下面是其创建线程池的四个静态方法,分别对应不同类型线程池:
newCachedThreadPool()
:创建一个可缓存的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,否则新建线程。(线程最大并发数不可控制)newFixedThreadPool()
:创建一个固定大小的线程池,可控制线程最大并发数,超出的线程会在队列中等待。newScheduledThreadPool()
: 创建一个定时线程池,支持定时及周期性任务执行。newSingleThreadExecutor()
:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(如FIFO, LIFO, 优先级)执行。
12.ThreadPoolExecutor
参数含义
1 | public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime, |
corePoolSize
:线程池核心线程数。当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize,如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行,如果执行了线程池的prestartAllCoreThreads()
方法,线程池会提前创建并启动所有核心线程maximumPoolSize
:线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize。如果阻塞队列是无上限的,则该参数没有用keepAliveTime
:线程池维护线程所允许的空闲时间。当线程池中的线程数量大于corePoolSize时候,如果这时候没有新的任务提交,核心线程外的线程不会立即被销毁,而是会等待,直到等待的时间超过了keepAliveTimeunit
:keepAliveTime的单位时间workQueue
:用于保存等待被执行的任务的阻塞队列,且任务必须实现Runnable接口,在JDK中提供了如下阻塞队列:ArrayBlockingQueue
:基于数组结构的有界阻塞队列,按FIFO排序任务。LinkedBlockingQueue
:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQueue。SynchronousQueue
:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常高于LinkedBlockingQueue。PriorityBlockingQueue
:具有优先级的无界阻塞队列。
threadFactory
:ThreadFactory类型的变量,用来创建新线程。ThreadFactory.defaultThreadFactory是创建线程的默认值, 会使新创建线程具有相同的NORM_PRIORITY优先级并且都是非守护线程,同时也设置了线程名称。handler
:线程池的饱和(拒绝)策略。当阻塞队列满了,且没有空闲的工作队列,如果继续提交任务,必须采用一种策略处理该任务。常用的拒绝策略如下:- AbortPolicy拒绝策略:抛出运行时异常RejectedExecutionException。 这种策略丢弃任务并抛出异常,是默认采用的策略
- DiscardPolicy 拒绝策略:不能执行的任务将被丢弃。 这种策略什么都没做。
- DiscardOldestPolicy 拒绝策略:如果执行程序尚未关闭,则位于工作队列头部的任务将被删除,然后重试执行程序。该策略稍微复杂一些,在pool没有关闭的前提下首先丢掉缓存在队列中的最早的任务,然后重新尝试运行该任务。
- CallerRunsPolicy 拒绝策略:线程调用运行该任务的 execute 本身。此策略提供简单的反馈控制机制能减缓新任务的提交速度
13.Java内存模型
Java内存模型即JMM(Java Memory Model)。用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各平台下都能够达到一致的内存访问效果。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
JMM与Java内存结构并不是同一个层次的内存划分,两者基本没有关系。如果非要对应,那从变量、主内存、工作内存的定义看,主内存主要对应Java堆中的对象实例数据部分,工作内存则对应虚拟机栈的部分区域。
主内存:主要存储Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。在共享数据区域,多条线程对同一个变量进行访问操作可能会发现线程安全问题。
工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
14.轻量级锁、偏向锁
轻量级锁:轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量(重量级锁)进行同步。 当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的Mark Word更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的Mark Word的锁标记变为 00,表示该对象处于轻量级锁状态。 如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。
如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁
偏向锁:偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。 当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。 当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向后恢复到未锁定状态或者轻量级锁状态。
偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定
简单对比
- 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁
- 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争
- 重量级锁:有实际竞争,且锁竞争时间长
15.notify()
和notifyall()
区别
等待池:假设线程A调用了某个对象的wait(),线程A就会释放对象的锁,同时线程A就进入了该对象的等待池中,进入等待池中的线程将不会去竞争该对象的锁
notifyAll()
会让所有处于等待池的线程全部进入锁池去竞争锁的机会notify()
只会随机选取一个等待池的线程进入锁池去竞争获取锁的机会,因为sychronized是非公平锁,不会按顺序竞争锁
16.AQS是什么
AQS,AbstractQueuedSynchronizer,抽象队列同步器。简单的说AQS就是一个抽象类,仅仅定义了同步状态state的获取和释放的方法。它提供了一个先进先出的队列,多线程竞争资源的时候,没有竞争到的线程就会进入队列中进行等待。在AQS中的锁类型有两种:分别是Exclusive(独占锁)和Share(共享锁)。
- 独占锁:只允许有一个线程运行,例如ReentrantLock。
- 共享锁:允许有多个线程运行,如Semaphore、CountDownLatch、ReentrantReadWriteLock
实现原理:AQS中维护了一个先进先出的队列,且该队列是一个双向链表,链表中的每一个节点为Node节点,Node类是AbstractQueuedSynchronizer中的一个内部类,有前驱prev和后继next。AQS依赖这个同步队列来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程的信息封装成一个Node节点加入到同步队列,当同步队列中的线程会不断的通过自旋去获取同步状态,如果获取了锁,就把其设为同步队列中的头节点,否则继续自旋等待。
17.Java线程池异常处理方案
在使用线程池的execute()和submit()执行一个带有运行时异常的任务时,会发现前者任务运行会出现异常信息,后者的任务正常执行。线程池的两种不同的提交方式,会有不同的异常情形,但是不管怎样,线程内的任务抛出异常之后,线程池照样能正常运行。
解决方案一:使用try-catch捕获异常
解决方案二:调用Thread的setUncaughtExceptionHandler()方法,UncaughtExceptionHandler 是Thread类一个内部类,也是一个函数式接口。内部的uncaughtException是一个处理线程内发生的异常的方法,参数为线程对象t和异常对象e。可以自己实现的一个线程池,重写它的线程工厂方法,在创建线程的时候,都赋予UncaughtExceptionHandler处理器对象。
18.ThreadLocal实现与存在问题
ThreadLocal
实现
ThreadLocal
也叫线程本地变量。其作用域覆盖线程,即变量与线程的生命周期相同。ThreadLocal
采用线程与任务剥离的思想,从而达到线程封闭的目的,帮助设计出更易维护的线程安全类。
每个Thread
线程对象中都有一个 ThreadLocalMap
类型的成员threadLocals
,执行set()
方法将值是保存在当前线程的threadLocals
变量中,执行get()
方法中从当前线程的 threadLocals
变量获取值,也就是说最终变量是放在当前线程的 ThreadLocalMap
中,ThreadLocal
可以理解为ThreadLocalMap
的封装,传递了变量值。在不同线程中threadLocals
成员不同,这也就保证了线程之间不会相互干扰。threadLocals
成员变量通常存储一个键值对,key值为一个Threadlocal
实例,value值为对应变量
ThreadLocalMap
没有实现Map
接口,但也具有常见Map
实现类的大部分属性。最特殊的地方在于Entry的实现,Entry继承了以ThreadLocal
作为泛型类型的WeakReference
引用,继承WeakReference
是为了方便垃圾回收。对于弱引用WeakReference
,当一个对象仅仅被弱引用指向, 而没有任何其他强引用指向时GC运行该对象就会被回收。
存在问题
ThreadLocal变量采用了弱引用,所以如果线程死亡,线程中的ThreadLocalMap
实例也就是threadLocals
变量也将被垃圾回收器回收,但如果任务对象结束而线程实例仍然存在(常见于线程池使用中需要复用线程实例),则仍然会发生内存泄露,即key值为null但value值仍然存在且只是占内存却不使用。这时候就需要手动地调用remove()
方法来主动清除ThreadLocalMap
中key为null的Entry了。
19.Collections.synchronizedMap()
原理
HashMap
本身是非线程安全的集合类,但是当使用Collections.synchronizedMap()
这个集合工具类的静态方法进行包装后就会得到一个线程安全的Map
。该静态方法会返回一个SynchronizedMap
实例。SynchronizedMap
类是Collections
中定义的一个静态内部类,实现了Map
接口,并对其中的每个方法(方法操作的是被包装的HashMap
)都加上了synchronized
关键字来实现同步控制,类似于Hashtable
的同步方式
可以发现Collections.synchronizedMap()
得到的线程安全的SynchronizedMap
在调用Map方法时需要对整个HashMap进行同步,并发性较差,而使用ConcurrentHashMap
只会对当前桶数组下标的元素进行同步,实现更加精细,并发性要好得多。
Web框架
1.MVC模式是什么
MVC,Model View Controller,是一种架构模式,用一种业务逻辑、数据、界面显示分离的方法组织代码,将业务逻辑聚集到一个部件里面,在改进和个性化定制界面及用户交互的同时,不需要重新编写业务逻辑。MVC模式认为程序不论简单或复杂,从结构上看,都可以分成三层。
- 最上面的一层,是直接面向最终用户的视图层(View)。它是提供给用户的操作界面,是程序的外壳。
- 最底下的一层,是核心的数据层(Model),也就是程序需要操作的数据或信息。
- 中间的一层,就是控制层(Controller),它负责根据用户从视图层输入的指令,选取数据层中的数据,然后对其进行相应的操作,产生最终结果。
2.Mybatis插件原理
Mybatis采用责任链模式,通过动态代理组织多个插件(拦截器)**,通过这些插件可以改变Mybatis的默认行为如SQL的重写,MyBatis 是通过动态代理**的方式实现拦截的,它允许在已映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:
Executor
:拦截执行器的方法。Executor
是 Mybatis的内部执行器,它负责调用StatementHandler
操作数据库,并把结果集通过ResultSetHandler
进行自动映射,另外还处理了二级缓存的操作。从这里可以看出,我们也是可以通过插件来实现自定义的二级缓存的。ParameterHandler
:拦截参数的处理。ParameterHandler
是Mybatis实现Sql入参设置的对象。插件可以改变我们Sql的参数默认设置ResultSetHandler
:拦截结果集的处理。ResultSetHandler
是Mybatis把ResultSet
集合映射成POJO的接口对象。我们可以定义插件对Mybatis的结果集自动映射进行修改。StatementHandler
:拦截Sql语法构建的处理。StatementHandler
是Mybatis直接和数据库执行sql脚本的对象。另外它也实现了Mybatis的一级缓存。这里可以使用插件来实现对一级缓存的操作(禁用等等)。
Mybatis的插件实现要实现Interceptor
接口,这个接口只声明了三个方法。
setProperties()
:在Mybatis配置插件时可以配置自定义相关属性,即接口实现对象的参数配置plugin()
:方法是插件用于封装目标对象的,通过该方法我们可以返回目标对象本身,也可以返回一个它的代理对象intercept()
:方法就是要进行拦截的时候要执行的方法
分页插件
PageHelper
通过实现Interceptor
接口的intercept()
方法,自定义ResultSetHandler
和StatementHandler
两个插件从而实现在SQL添加limit
关键字并返回分页结果
3.SSH、SSM框架异同
SSH:Struts2+Spring+Hibernate
SSM :SpringMVC+Spring+MyBatis
相同点:SSM和SSH都是企业常用的MVC开源框架。都通过Spring实现依赖注入DI来管理各层的组件,并且都是使用面向切面编程AOP来管理事物、日志、权限等。
不同点:在于MVC实现方式,以及ORM持久化方面不同(Hiibernate与Mybatis)。SSM越来越轻量级配置,将注解开发发挥到极致,且ORM实现更加灵活,SQL优化更简便;而SSH较注重配置开发,其中的Hiibernate对JDBC的完整封装更面向对象,对增删改查的数据维护更自动化,但SQL优化方面较弱,且入门门槛稍高。
4.SpringMVC为什么用适配器模式
适配器模式:有一个类接口,但这个接口不符合预期,如果想要使用就需要在其源码上进行一些修改,这时可以做一个适配器类继承这个被适配类,这样就可以在不修改被适配接口源码的情况下,在适配器上对这个接口进行运用(组合使用其他类),使得适配器符合你的规范。
实现方式:
- 适配器与被适配接口是继承关系
- 适配器内组合另一个被适配接口
SpringMVC适配器模式的应用:
Spring创建了一个适配器接口HandlerAdapter
,拥有方法supports()
、handle()
使两种方法。
supports()
:传入处理器参数,判断是否与当前适配器支持。如果支持则从DispatcherServlet
中的HandlerAdapter
实现类中返回支持的适配器实现类。handle()
:返回后适配实现类中代理Controller
执行请求的方法返回的ModelAndView
然后让一些适配器实现类(比较常见的就是
Controller
对应的适配器)接口去继承这个适配器,然后适配器实现类通过实现supports()
、handle()
来调用其对应的处理器Controller
来返回ModelAndView
5.RestTemplate
介绍
RestTemplate
是Spring提供的一个访问HTTP服务的客户端类,类似JdbcTemplate
。并且它是线程安全的。直接从它名称上来看,就能看出该类主要是针对Restful风格的API设计的,因此它有与GET、PUT、DELETE、POST相关的方法
6.Spring如何解决循环依赖问题
循环依赖:指的是多个类循环嵌套引用,循环依赖会导致程序在运行时一直循环调用,直至内存溢出报错。但Spring有相应的解决办法的,Spring对循环依赖的处理有三种情况:
- 构造器的循环依赖:处理不了,直接抛出BeanCurrentlylnCreationException异常
- 单例模式下的setter循环依赖:通过三级缓存处理循环依赖。
- 非单例循环依赖:无法处理
spring设置三级缓存,这三级缓存的作用分别是:
singletonFactories
:进入实例化阶段的单例对象工厂的Cache(三级缓存)earlySingletonObjects
:完成实例化但是尚未初始化的,提前曝光的单例对象的Cache(二级缓存)singletonObjects
:完成初始化的单例对象的Cache(一级缓存)
在创建bean时,首先会从Cache中获取这个bean,这个缓存就是sigletonObjects(一级缓存),如果一级缓存没有则会去earlySingletonObjects(二级缓存)找,二级缓存也没有则会去singletonFactories(三级缓存)找
下面假设对象A依赖对象B,对象B依赖对象A形成循环依赖。A首先完成了初始化的第一步,并且将自己提前曝光到三级缓存,此时进行初始化的第二步发现自己依赖对象B,此时就尝试去缓存中获取对象B,但发现B还没有被创建,所以先创建对象B,B在初始化第一步的时候发现自己依赖了对象A,于是尝试去缓存中获取对象A,尝试一级缓存没有后继续尝试二级缓存,二级缓存也没有后继续尝试三级缓存,由于A前面已经将自己尚未初始化的引用提前曝光了,所以B能够通过缓存拿到A对象(虽然A还没有初始化完全),对象B拿到对象A后完成了初始化,并将自己放入到一级缓存。此时再回到A的初始化过程,现在对象B已经彻底创建好了,A就可以通过缓存来拿到B的对象,也顺利完成自己的初始化阶段。而前面B拿到A的对象引用此时也有了值
7.SpringBoot启动过程
- 执行SpringApplication.run()方法,初始化SpringApplication对象,收集各种条件和回调接口
- 通过
SpringFactoriesLoader
加载META-INF/spring.factories
文件,获取并创建监听器SpringApplicationRunListener
对象,调用starting()方法 - 创建参数并准备环境Environment,依然由监听器来调用environmentPrepared()
- 创建
ApplicationContext
并进行初始化,设置Environment加载的相关配置,由监听器调用 contextPrepared()。将各种 bean装载入ApplicationContext
,继续由监听器调用contextLoaded() - 刷新容器,完成IoC容器可用的最后一步,监听器调用started()
- 完成最终的程序的启动,监听器调用running()
8.BeanFactory
和ApplicationContext
的区别
BeanFactory
和ApplicationContext
都是接口,ApplicationContext
间接继承了BeanFactory
。
BeanFactory
是Spring中最底层的接口,提供了最简单的容器的功能,只提供了实例化对象和获取对象的功能,BeanFactory的实现使用懒加载的方式,这意味着bean只有在通过 getBean() 方法直接调用时才进行实例化ApplicationContext
是Spring的一个更高级的容器,提供了更多的有用的功能:获取Bean的详细信息(如定义、类型)、国际化的功能、统一加载资源的功能、强大的事件机制、对Web应用的支持等等。且ApplicationContext的实现使用预加载的方式,这意味着bean在ApplicationContext容器启动皇后都会完成实例化,这种方式的好处是可以立刻发现Spring配置文件中的错误,坏处是会存在资源浪费。
9.Spring中bean的整个生命周期
- Spring容器启动后,查找并加载需要被Spring管理的Bean,进行实例化
- Bean实例化后对将Bean的引用和值注入到Bean的属性中
- 若Bean实现BeanNameAware接口(命名Bean组件)的话,Spring将Bean的Id传递给setBeanName()方法,如果实现了其他的一些XXAware接口,同样也会执行setXX()方法
- 若Bean 实现InitializingBean接口,Spring将调用他们的afterPropertiesSet()方法。类似的,如果bean使用init-method声明了初始化方法,该方法也会被调用
- 若Bean实现BeanPostProcessor接口(后置处理器),就会调用postProcessAfterInitialization()
- 此时Bean准备就绪,可以被应用程序使用,其将一直驻留在应用上下文中,直到应用上下文被销毁。
- 当Bean销毁时,若Bean实现DisposableBean接口,Spring将调用destory()接口方法,同样,如果bean使用了destory-method 声明销毁方法,该方法也会被调用。
10.IOC、DI概念
IoC,Inversion of Control,控制反转。是说创建对象的控制权进行转移,以前创建对象的主动权和创建时机是由自己把控的(对象需要通过new关键字来创建,如果对象之间存在依赖关系则代码的耦合度很高),而现在这种权力转移到第三方,比如转移交给了IoC容器,它就是一个专门用来创建对象的工厂,你要什么对象,它就给你什么对象,有了IoC容器,依赖关系就变了,原先的依赖关系就没了,它们都依赖IoC容器了,通过IoC容器来维护对象之间的关系。这样程序员不再需要关心依赖关系这种细节问题,只需要在使用时通过依赖注入即可获取对象
DI,Dependency Injection,依赖注入。组件之间依赖关系由容器在运行期决定,形象的说就是由容器动态的将某个依赖关系注入到组件之中。
IoC和DI其实是同一个概念的不同角度描述,控制反转的概念比较含糊,可能只是理解为容器控制对象这一个层面,很难让人想到谁来维护对象关系,所以又有了依赖注入来明确描述被注入对象依赖IoC容器配置依赖对象。
11.AOP基本概念
概念:AOP,Aspect-oriented programming(面向切面编程),横切关注点与业务逻辑相分离,切面能帮助我们模块化横切关注点;
优点
- 实现代码重用,将通用的部分集中于一处
- 业务模块更简洁,只需要包含主要代码,而次要通用的代码被转移到切面
常用术语
- 连接点(join point):方法。程序执行过程中明确的点,如方法的调用或特定的异常被抛出。连接点由以下两个信息确定,简单来说,连接点就是被拦截到的程序执行点,因为Spring只支持方法类型的连接点,所以在Spring中连接点就是被拦截到的方法
- 方法(表示程序执行点,即在哪个目标方法)
- 相对点(表示方位,即目标方法的什么位置,比如调用前后等)
- 切点(pointcut):用切点表达式指定的方法。切点是对连接点进行拦截的条件定义。切点表达式如何和连接点匹配是AOP的核心,Spring缺省使用AspectJ切入点语法。一般不会在所有的方法上都添加通知,而切点的作用就是提供一组规则来匹配连接点,给满足规则的连接点添加通知。
- 通知(advice):拦截到连接点后执行的代码。有以下五种
- 前置通知(Before):在目标方法被调用之前调用通知功能;
- 后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么
- 返回通知(After-returning):在目标方法成功执行之后调用通知
- 异常通知(After-throwing):在目标方法抛出异常后调用通知
- 环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行 自定义的行为
- 切面(Aspect): 切面是一个横切关注点的模块化,一个切面能够包含同一个类型的不同增强方法,比如说事务处理和日志处理可以理解为两个切面。切面由切点和通知组成,它既包含了横切逻辑的定义,也包括了切入点的定义
- 目标对象(Target): 目标对象指将要被增强的对象,即包含主业务逻辑的类对象。或者说是被一个或者多个切面所通知的对象
- 织入(Weaving):织入是将切面和业务逻辑对象连接起来, 并创建通知代理的过程。织入可以在编译时,类加载时和运行时完成。在编译时进行织入就是静态代理,而在运行时进行织入则是动态代理
- 连接点(join point):方法。程序执行过程中明确的点,如方法的调用或特定的异常被抛出。连接点由以下两个信息确定,简单来说,连接点就是被拦截到的程序执行点,因为Spring只支持方法类型的连接点,所以在Spring中连接点就是被拦截到的方法
12.AOP原理
AOP的实现实际上是用的是代理模式
代理概念:为某一个对象创建一个代理对象,不直接引用原本的对象,而是由创建的代理对象来控制对原对象的引用。按照代理的创建时期,代理类可以分为两种。
- 静态代理:由程序员创建或特定工具自动生成源代码,再对其编译。在程序运行前,代理类的.class文件就已经存在了。
- 动态代理:在程序运行时通过反射机制动态创建而成,无需手动编写代码。动态代理不仅简化了编程工作,而且提高了软件系统的可扩展性,因为Java反射机制可以生成任意类型的动态代理类。
静态代理模式:静态代理是指AOP框架在编译阶段生成AOP代理类,因此也称为编译时增强。ApsectJ是静态代理的实现之一,也是最为流行的。静态代理由于在编译时就生成了代理类,效率相比动态代理要高一些。AspectJ可以单独使用,也可以和Spring结合使用。
动态代理模式:与静态代理不同,动态代理就是说AOP框架不会去修改编译时生成的字节码,而是在运行时在内存中生成一个AOP代理对象,这个AOP对象包含了目标对象的全部方法,并且在特定的切点做了增强处理,并回调原对象的方法。Spring AOP中的动态代理主要有两种方式:JDK动态代理和CGLIB动态代理。
- JDK代理通过反射来处理被代理类,并且要求被代理类要实现一个接口。核心类是
InvocationHandler
接口和Proxy
类。 - 而当目标类没有实现接口时,Spring AOP框架会使用CGLIB来动态代理目标类。CGLIB是一个代码生成的类库,可以在运行时动态的生成某个类的子类。 CGLIB是通过继承的方式做的动态代理,因此如果某个类被标记为final,那么它是无法使用CGLIB做动态代理。核心类是
MethodInterceptor
接口和Enhancer
类
- JDK代理通过反射来处理被代理类,并且要求被代理类要实现一个接口。核心类是
13.过滤器和拦截器的区别
- 概念:拦截器采用面向切面编程,在Servlet的service()方法前后调用,过滤器处于客户端与Web资源之间,客户端与Web资源之间的请求和响应都要通过过滤器进行过滤
- 实现原理:拦截器基于Java反射和动态代理实现,过滤器基于函数回调
- 规范:拦截器是Spring支持的,不依赖于Servlet容器,过滤器是Servlet容器支持的
- 触发时机:整个执行流程为过滤前 - 拦截前 - Action处理 - 拦截后 - 过滤后。拦截器能深入方法前后,异常抛出前后,过滤器只在Servlet执行前后起作用
- 访问资源:拦截器可以访问Spring容器的任何资源、对象,过滤器不能
- 调用次数:在Action的生命周期中,拦截器可以被多次调用,而过滤器只能在容器初始化的时候被调用一次
14.Spring与Spring Boot的区别
- Spring是用于构建应用程序的广泛使用的Java EE框架;Spring Boot被广泛用于开发REST API
- Spring的主要功能是依赖项注入;Spring Boot的主要功能是自动配置,能够根据需求自动生成组件
- Spring需要编写了大量样板代码来实现(如完成一个小项目时配置文件代码量甚至要大于业务代码量);Spring Boot减少了不必要的样板代码
- Spring在测试时需要显式部署到服务器;Spring Boot提供了Jetty和Tomcat等内嵌的服务器直接就能启动测试
- Spring不提供对内存数据库的支持;Spring Boot提供了几个插件来处理嵌入式和内存数据库(如H2)
15.Spring Boot与Spring MVC的区别
- Spring Boot是Spring的模块,用于使用合理的默认值打包基于Spring的应用程序;Spring MVC是Spring框架下基于模型视图控制器的Web框架。
- Spring Boot提供了默认(自动)配置来构建Spring支持的框架;Spring MVC提供了用于构建Web应用程序的即用型功能
- Spring Boot无需手动构建配置;Spring MVC需要手动进行构建配置,要配置springMVC相对应的xml文件
- Spring Boot不需要部署描述符;Spring MVC需要部署描述符,要在
web.xml
文件上进行声明 - Spring Boot将依赖项包装在一个单元(starter)中; Spring MVC需要指定多个依赖项(导入多个jar包)
16.Mybatis缓存机制
使用缓存的目的是提升查询效率和减少数据库压力,MyBatis的缓存分为一级缓存和二级缓存
一级缓存:在一次数据库会话中,可能会执行多次查询条件完全相同的SQL,MyBatis提供一级缓存来优化这部分场景,如果是相同的SQL语句,会优先命中一级缓存,避免直接对数据库进行查询,从而提高性能。当用户发起查询时,MyBatis会根据当前执行的语句生成MappedStatement
,在一级缓存中进行查询,如果缓存命中就直接返回结果给用户,如果缓存未命中就查询数据库返回结果给用户,并同时写入一级缓存
特点
- 一级缓存的生命周期和
SqlSession
一致 - 一级缓存的内部设计比较简单,只有一个无容量限定的
HashMap
,在作为缓存的功能性上有所欠缺 - 一级缓存的范围有
Session
(默认)和Statement
- 在进行修改数据操作后一级缓存会失效
二级缓存:一级缓存最大的共享范围为同一个SqlSession
,如果想要多个SqlSession
间实现共享缓存,则需要使用到二级缓存。开启二级缓存后,在查询时MyBatis会使用CachingExecutor
装饰Executor
,进入一级缓存的查询流程前,先在CachingExecutor
进行二级缓存的查询,其次才是一级缓存,缓存中都没有数据则查询数据库
特点
- 二级缓存的范围为
namespace
- 二级缓存通过
Cache
接口实现类不同的组合,对Cache
的可控性也更强 - 通过二级缓存进行多表查询时,极大可能会出现脏数据,有设计上的缺陷(修改了数据库数据后缓存数据没变)
MyBatis框架提供的缓存功能存在一些缺陷,所以建议在生产环境中进行关闭其缓存功能,把它单纯地作为一个ORM框架使用更为合适。
17.Spring使用的设计模式
- 工厂模式:Spring通过
BeanFactory
或ApplicationContext
创建 bean 对象 - 单例模式:Spring 中 bean 的默认作用域就是 singleton(单例)的
- 代理模式:Spring AOP 基于动态代理实现
- 观察者模式:Spring 事件驱动模型采用观察者模式
- 模板方法模式 : Spring 中
jdbcTemplate
、hibernateTemplate
等以 Template 结尾的对数据库操作的类用到了模板模式 - 适配器模式:Spring AOP 中用于增强/通知的
AdvisorAdapter
用到适配器模式;Spring MVC 中的对于各种不同的Controller
采用适配器模式进行适配
安全
1.基于Token的认证
传统的认证方法都是基于服务器的,因为HTTP协议是无状态的,所以程序会借助在服务端存储的登录信息来辨别请求,而这种方式一般都通过存储Session
来完成。但该方法也有相应的问题
- 每次认证用户发起请求时,服务器都要创建
Session
来存储信息,内存开销会越来越大 - 在服务端的内存中使用
Seesion
存储登录信息,对跨域资源共享和跨站请求伪造这两个问题都难以处理
而基于Token实现的认证方式也是无状态的,其不会将用户信息存在服务器。这种概念解决了上述的两个问题。NoSession
意味着程序可以根据需要去增减机器而不用去担心用户是否登录。因此在大多数使用Web API
的互联网公司中,Token
是多用户下处理认证的最佳方式。它的优点如下
- 无状态、可扩展:在客户端存储的
Token
是无状态的,且能够被扩展。基于这种无状态和不存储Session
信息,负载均衡器能够将用户信息从一个服务传到其他服务器上。tokens
自己存储了用户的验证信息。 - 支持移动设备、跨程序调用:在
CORS
(跨域资源共享)时需要对应用程序和服务进行扩展,并介入各种设备和应用程序。而Tokens
能够创建与其它程序共享权限的程序。 - 安全性:请求中发送
token
而不再是发送cookie
能够防止**CSRF
(跨站请求伪造)。即使在客户端使用cookie
存储token
,cookie
也仅仅是一个存储机制**而不是用于认证。不将信息存储在Session
中,减少了对session
的操作。并且token
也是有时效的,一段时间之后用户需要重新验证。
基于Token的身份认证流程
- 用户首次登陆通过用户名和密码发送请求。
- 服务器端程序验证。
- 服务器端程序返回一个带签名的
token
给客户端。 - 客户端储存
token
(放在Cookie
或Local Storage
),并且每次访问API
都携带Token
到服务器端 - 服务端验证
token
,校验成功则返回请求数据,校验失败则返回错误码。
2.跨站脚本攻击XSS
跨站脚本攻击(Cross-Site Scripting, XSS):XSS是一种经常出现在web应用中的计算机安全漏洞,也是web中最主流的攻击方式。具体指恶意攻击者利用网站没有对用户提交数据进行转义处理或者过滤不足的缺点,进而添加一些代码,嵌入到web页面中去。使别的用户访问都会执行相应的嵌入代码。从而盗取用户资料、利用用户身份进行某种动作或者对访问者进行病毒侵害的一种攻击方式。
危害
- 盗取各类用户帐号,如机器登录帐号、用户网银帐号、各类管理员帐号(窃取用户Cookie)
- 伪造虚假输入表单骗取个人信息
- 显示伪造的文章、图片
- 利用受害者机器向其它网站发起攻击
防范手段
- 设置 Cookie 为 HttpOnly:设置了HttpOnly的 Cookie 可以防止 JavaScript 脚本调用,就无法通过 document.cookie 来获取用户Cookie信息**
- 过滤特殊字符:如将
<
转义为<
;,将>
转义为>
;,从而避免HTML和Jascript代码的运行。 比如富文本编辑器通常会采用 XSS filter 来防范XSS攻击,通过定义一些标签白名单或者黑名单,从而不允许有攻击性的 HTML 代码(如form、script)的输入。
3.跨站请求伪造CSRF
跨站请求伪造(Cross-site request forgery,CSRF):CSRF是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并执行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去执行。而CSRF攻击者不需要控制放置恶意网址的网站,他可以直接将网站地址放在论坛,博客等任何用户生成内容的网站中让用户去访问。这意味着如果服务器端没有合适的防御措施的话,用户即使访问熟悉的可信网站也有受攻击的危险。CSRF 攻击不能直接获取用户的账户控制权,也不能直接窃取用户的任何信息。能做的就是欺骗用户浏览器,让其以用户的名义执行操作。
XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户浏览器的信任
防范手段
- 检查 Referer 首部字段:Referer 首部字段位于 HTTP 报文中,用于标识请求来源的地址。检查这个首部字段并要求请求来源的地址在同一个域名下,可以极大的防止 CSRF 攻击。 这种办法简单易行,工作量低,仅需要在关键访问处增加一步校验。但这种办法也有其局限性,因其完全依赖浏览器发送正确的 Referer 字段。虽然 HTTP 协议对此字段的内容有明确的规定,但并无法保证来访的浏览器的具体实现,亦无法保证浏览器没有安全漏洞影响到此字段。并且也存在攻击者攻击某些浏览器,篡改其 Referer 字段的可能
- 添加校验:Token 在访问敏感数据请求时,要求用户浏览器提供不保存在 Cookie 中,并且攻击者无法伪造的数据作为校验。
- 输入验证码:因为 CSRF 攻击是在用户无意识的情况下发生的,所以要求用户输入验证码可以让用户知道自己正在做的操作
4.预编译为什么可以防止SQL注入
防止SQL注入可以用PrepareStatement来实现参数注入,而在底层原因是因为SQL语句在程序运行前就进行了预编译。因为预编译发生在执行之前,所以在程序运行操作数据库前,SQL语句就已经被数据库分析、编译和优化了,对应的执行计划会缓存下来并允许数据库以参数化形式进行查询,这时动态地传递参数给PrepareStatement即使参数有敏感字符也会被当做值来处理而不是SQL指令,从而防止SQL注入
5.ARP攻击
背景:ARP,Address Resolution Protocol,地址解析协议。是网络层协议,负责将某个IP地址解析成对应的MAC地址。一台主机和另一台主机通信,要知道目标的IP地址,但是在局域网中传输数据的网卡不能直接识别IP地址,所以要通过ARP协议将IP地址解析成MAC地址。ARP协议的基本功能就是通过目标设备的IP地址,来查询目标设备的MAC地址。
存在问题:在局域网的任意一台主机中,都有一个ARP缓存表,里面保存本机已知的此局域网中各主机和路由器的IP地址和MAC地址的对照关系。ARP协议是建立在信任局域网内所有节点的基础上的,效率很高但不安全,因为它是无状态的协议,不会检查自己是否发过请求包,也不知道自己是否发过请求包。因此不管是否是合法的应答,只要收到目标MAC地址是自己的ARP应答包,都会接受并缓存
ARP攻击原理、过程:ARP欺骗攻击建立在局域网主机间相互信任的基础上的。当A发广播询问:我想知道IP是192.168.0.3的硬件地址是多少?此时B当然会回话:我是IP192.168.0.3我的硬件地址是mac-b,可是此时IP地址是192.168.0.4的C也非法回了:我是IP192.168.0.3,我的硬件地址是mac-c。而且是大量的。所以A就会误信192.168.0.3的硬件地址是mac-c,而且动态更新缓存表,这样主机C就劫持了主机A发送给主机B的数据,这就是ARP欺骗的过程。假如C直接冒充网关,此时主机C会不停的发送ARP欺骗广播,说:我的IP是192.168.0.1,我的硬件地址是mac-c,此时局域网内所有主机都被欺骗,更改自己的缓存表,此时C将会监听到整个局域网发送给互联网的数据报。
受ARP攻击后的常见表现:
- 打开网页速度非常慢,甚至打不开
- 提示IP地址冲突
- 甚至导致校园网瘫痪断网
- 一般会绑定木马病毒,窃取用户账号密码
ARP攻击的形式:
协议内部方面
- 假冒ARP应答包:向单台主机或多台主机发送虚假的IP/MAC地址
- 假冒ARP请求包:实际上是单播或广播虚假的IP、MAC映射。
- 假冒中间人:启用包转发向两端主机发送假冒的ARP reply,由于ARP缓存的老化机制,有时还需要做周期性连续性欺骗。
网络连接方面
- 对路由ARP表的欺骗:ARP病毒截获网关数据,让路由器获得错误的内网MAC地址,导致路由器把数据发送给错误的mac,使内网内的主机断网
- 伪造内网网关:ARP病毒通过冒充网关,是内网计算机发送的数据无法到达真正的路由器网关,导致内网计算机断网
6.SYN洪泛攻击及抵御方法
SYN洪泛攻击:SYN Flood是常见的DDoS攻击,它利用TCP协议缺陷来发送大量伪造的TCP连接请求,从而使得服务器资源耗尽(CPU满负荷或内存不足)。当服务器开放一个TCP端口后,该端口就处于Listening
状态,会不停监听发到该端口的SYN报文,一旦接收到客户端(可能是普通用户也可能是攻击机器)通过第一次握手发来的SYN报文,就需要为该请求分配一个TCB(传输控制块),一个TCB一般至少需要280个字节,此时服务器通过第二次握手返回一个带SYN和ACK的报文后转为SYN-RECEIVED
状态。如果攻击者恶意地向服务器端口发送大量的SYN包,则会和服务器建立大量的半开连接并分配TCB,从而消耗大量的服务器资源,同时使正常的连接请求无法被响应。
抵御方法
无效连接监视释放:这种方法需要不停监视系统的半开连接和不活动连接,当达到一定阈值时拆除这些连接,从而释放系统资源。这种方法对于所有的连接一视同仁,而且由于SYN Flood造成的半开连接数量很大,正常连接请求也被淹没在其中被这种方式误释放掉,因此这种方法属于入门级的SYN Flood方法。
延缓TCB分配:SYN攻击会消耗服务器资源是因为服务器需要为半开连接分配TCB资源。而SYN Flood由于很难建立起正常连接,因此当正常连接建立起来后再分配TCB则可以有效地减轻服务器资源的消耗。常见的方法是使用Syn Cache和Syn Cookie技术。
- Syn Cache技术:在收到SYN数据报文时不急于分配TCB,而是先进行第二次握手回应一个带SYN和ACK的报文(在回应时要发送一个序列数来防止一些更加智能的SYN攻击会进行第三次握手建立真正的连接),并在一个专用HASH表(Cache)中保存这种半开连接信息,直到收到正确的回应ACK报文再分配TCB。
- Syn Cookie技术:Syn Cookie技术完全不使用任何存储资源,而是用一种特殊算法生成序列数,这种算法考虑到对方IP、端口、己方IP、端口的固定信息,以及对方无法知道而己方比较固定的一些信息,如MSS、时间等,在收到对方的ACK报文后,还会重新计算一遍,看其是否与对方回应报文中的序列数相同,从而决定是否分配TCB资源。
使用SYN Proxy防火墙:Syn Cache技术和Syn Cookie技术总的来说是一种主机保护技术,需要系统的TCP/IP协议栈的支持,而目前并非所有的操作系统支持这些技术。因此很多防火墙中都提供一种SYN代理的功能,其主要原理是对试图穿越的SYN请求进行验证后才放行。
7.DOS和DDOS攻击
DOS攻击:Denial of Service,拒绝服务攻击。它是一种实现简单有效的针对服务器进行的攻击方式,其目的是让被攻击的主机和服务器拒绝用户的正常访问,破坏系统正常运行 ,从而使互联网用户无法连接到被攻击的服务器和主机,造成系统瘫痪。
DDOS攻击:Distributed Denial of Service,分布式布拒绝服务攻击,它是在DOS基础上进行的大规模、大范围的攻击模式,DOS只是单机和单机之间的攻击模式,而DDOS是利用一大批受控制的僵尸主机(肉鸡)向一台服务器主机发起的攻击,其攻击的强度和造成的威胁要比DOS严重很多,也更具破坏性。
从理论上来说,无论目标服务器、网络服务的资源(带宽、内存)有多大,都无法避免Dos与DDOS攻击,因此任何资源再大也有一个极限值;而从技术上来说,DOS和DDOS都是攻击目标服务器的带宽和连通性,使目标服务器的带宽资源耗尽,无法正常运行。
Linux
1.Linux的文件属性
文件属性的描述符有10个字符,第 1 位为文件类型字段,后 9 位为文件权限字段
文件类型及其含义
- d:目录
- -:文件
- l:链接文件
文件权限及其含义
- r:可读
- w:可写
- x:可执行
- -:无当前权限
其中2-4位表示用户权限,5-7位表示用户所属用户组权限,8-10位表示其他人权限
- 文件默认权限:文件默认没有可执行权限,因此为 666,也就是 -rw-rw-rw-
- 目录默认权限:目录必须要能够进入,也就是必须拥有可执行权限,因此为 777 ,也就是 drwxrwxrwx
文件名不是存储在一个文件的内容中,而是存储在一个文件所在的目录中。因此,拥有文件的 w 权限并不能对文件名进行修改
2.Linux常用命令有哪些
ls
: 列出文件/目录的信息cd
:更换当前目录mkdir
:创建目录rmdir
:删除目录touch
: 更新文件时间/建立新文件。cp
: 复制文件rm
:删除文件mv
: 移动文件cat
: 打印文件内容tac
: 反向打印文件内容more
: 分页查看文件内容,不能回退less
: 分页查看文件内容,可以回退head
: 打印文件前N行tail
:打印文件末N行od
: 以字符/十六进制的形式显示二进制文件-
which
: 指令搜索 -
whereis
: 文件搜索。 locate
: 文件搜索。find
: 文件搜索。ps
: 查看某个时间点的进程信息pstree
: 查看进程树top
: 实时显示进程信息netstat
: 查看占用端口的进程
3.Linux的进程状态
- R—— running or runnable :正在执行或者可执行。此时进程位于执行队列中。
- D——uninterruptible sleep:不可中断阻塞。通常为 IO 阻塞。
- S——interruptible sleep:可中断阻塞。此时进程正在等待某个事件完成
- Z——zombie: 僵死。进程已经终止但是尚未被其父进程获取信息
- T ——stopped: 结束。进程既可以被作业控制信号结束,也可能是正在被追踪。
4.孤儿进程和僵尸进程
孤儿进程:父进程退出,而其子进程还在运行,那么这些子进程将成为孤儿进程。 孤儿进程将被 init 进程(进程号为 1)所收养,并由 init 进程对它们完成状态收集工作。 由于孤儿进程会被 init 进程收养,所以孤儿进程不会对系统造成危害。
僵尸进程:子进程的进程描述符在子进程退出时不会释放,只有当父进程通过
wait()
或waitpid()
获取子进程信息后才会释放。如果子进程退出,而父进程并没有调用wait()
或waitpid()
,那么子进程的进程描述符仍然保存在系统中,这种进程称之为僵尸进程。wait()
: 父进程调用wait()
会一直阻塞,直到收到子进程的退出信号,之后wait()
函数会销毁子进程并返回。 如果成功,返回被收集的子进程的进程 ID;如果调用进程没有子进程,调用就会失败,此时返回 -1,waitpid()
: 作用和wait()
相同,只不过多了两个可由用户控制的参数pid
和options
。pid
参数指定子进程ID,表示只关心目标子进程的退出信号,但如果pid = -1
时,那么和wait()
作用相同,即关心子进程的退出信号。options
参数有WNOHANG
和WUNTRACED
两个选项,WNOHANG
可以使waitpid()
调用变成非阻塞的,也就是说它会立即返回,父进程可以继续执行其它任务。
僵尸进程通过 ps 命令显示出来的状态为 Z。 系统所能使用的进程号是有限的,如果产生大量僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程。 而要消灭系统中大量的僵尸进程,只需要将其父进程杀死,此时僵尸进程就会变成孤儿进程,从而被 init 进程所收养,这样 init 进程就会释放所有的僵尸进程所占有的资源,从而结束僵尸进程。
计算机网络
1.物理层的通信方式
根据信息在传输线上的传送方向,分为以下三种通信方式:
- 单工通信:单向传输
- 半双工通信:双向交替传输
- 全双工通信:双向同时传输
2.三次握手与四次挥手的原因
- TCP建立连接的第三次握手:是为了防止失效的连接请求(延迟了一段时间后才发送成功)到达服务器,让服务器错误打开连接。客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。如果有第三次握手,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,不进行第三次握手,因此就不会再次打开连接。
- TCP断开连接的二、三次挥手分开发送分组:是因为客户端发送了 FIN 连接释放报文给服务端后,服务端进入了CLOSE-WAIT 状态,该状态是为了让服务器端发送还未传送完毕的数据,因此服务端会先进行第二次挥手发回确认分组,等到自己的数据传送完后则会再进行第三次挥手发送 FIN 连接释放报文。
- 客户端在第二次挥手后进入TIME_WAIT 状态并等待 2MSL而不是直接进入 CLOSED 状态: 是因为要确保最后一个确认报文能够到达。如果服务端没收到客户端第四次挥手发送的确认报文,那么就会重新进行第三次挥手发送连接释放请求报文,而让客户端等待一段时间就是为了处理这种情况的发生。
MSL(Maximum Segment Lifetime):分组的最大存活时间
3.TCP可靠的原因
确认和重传机制:建立连接时三次握手同步双方的“序列号 + 确认号 + 窗口大小信息”,是确认重传、流控的基础传输过程中,如果校验失败、丢包或延时,发送端重传
数据排序:TCP有专门的序列号SN字段,可提供数据重排
流量控制:窗口和计时器的使用。为了控制发送方发送速率,保证接收方来得及接收。接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。
拥塞控制:发送方需要维护一个叫做拥塞窗口cwnd的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。TCP的拥塞控制由4个核心算法组成
- 慢启动:发送发发送最初执行慢启动,令cwnd= 1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,后面报文段数量就如(2、4、8、…)一般地增长,但这种加倍可能会使发送方的发送速度增长过快,更可能导致网络拥塞,因此需要进行拥塞避免
- 拥塞避免:设置一个慢开始门限ssthresh,当拥塞窗口大小cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将cwnd加 1。 如果出现了超时则令慢开始门限ssthresh = cwnd / 2,然后重新执行慢开始。
- 快速重传 :接收方会对每次接收到报文段的最后一个已收到的有序报文段进行确认。 在发送方如果收到三个重复确认则可以确认当前确认序号的下一个报文段丢失,此时执行快重传,立即重传下一个报文段。
- 快速恢复:快速重传只是丢失个别报文段,而不是网络拥塞因此执行快恢复,令 ssthresh = cwnd / 2 ,cwnd = ssthresh,此时直接进入拥塞避免。
慢开始和快恢复的快慢指的是cwnd的设定值,而不是cwnd的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd设定为 ssthresh;流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度
4.Http请求方法类型
在客户端发送的Http请求中的请求行包含有Http方法,方法类型如下
- GET:显示请求指定的资源
- HEAD:获取报文首部。和 GET 方法类似,但是不返回报文实体主体部分,主要用于确认 URL 的有效性以及资源更新的日期时间等。
- OPTIONS: 请求服务器返回该资源所支持的所有HTTP请求方法。OPTIONS请求与HEAD类似,一般也用于客户端查看服务器的性能,测试服务器功能是否正常
- POST:向指定资源提交数据,请求服务器进行处理。POST 主要用来传输数据,而 GET 主要用来获取资源。
- PUT:向指定资源位置上传其最新内容
- PATCH:PATCH请求与PUT请求类似,同样用于资源的更新。
- DELETE:请求服务器删除所请求URI所标识的资源
- CONNECT:能够将连接改为管道方式的代理服务器。通常用于SSL加密服务器的链接与非加密的HTTP代理服务器的通信
- TRACE:追踪路径。服务器会将通信路径返回给客户端。 发送请求时,在 Max-Forwards 首部字段中填入数值,每经过一个服务器就会减 1,当数值为 0 时就停止传输。
PATCH和PUT的区别:PATCH一般用于资源的部分更新,而PUT一般用于资源的整体更新;当资源不存在时,PATCH会创建一个新的资源,而PUT只会对已在资源进行更新。
幂等性:幂等的 HTTP 方法,同样的请求被执行一次与连续执行多次的效果是一样的,服务器的状态也是一样的。GET,HEAD,PUT等方法都是幂等的,而 POST 方法不是(多次使用PUT请求会提交多条数据)。
5.常见HTTP状态码
- 100:继续。 客户端应继续其请求
- 200:请求成功
- 204:请求成功,但是返回的响应报文不包含实体的主体部分。
- 206:服务器成功处理部分GET请求,用于断点的下载续传
- 301 :永久性重定向
- 302 :临时性重定向
- 304:未修改。所请求的资源未修改,服务器返回此状态码时,不会返回任何资源。通常是客户端在请求静态文件(如CSS、图片)时发现本地有缓存服务端就会响应304
- 400:客户端请求的语法错误,服务器无法理解
- 403:服务器理解请求客户端的请求,但是拒绝执行此请求
- 404:服务器无法根据客户端的请求找到资源
- 405:客户端请求中的方法被禁止
- 500:服务器内部错误,无法完成请求
- 503:由于超载或系统维护,服务器暂时的无法处理客户端的请求
6.Cookie的创建过程
服务器发送给客户端的响应报文包含Set-Cookie
首部字段,客户端得到响应报文后会根据字段信息把 Cookie 内容保存到浏览器中,如知乎的Cookie响应报文段格式如下
1 | set-cookie: KLBRSID=abcdefghijk; Path=/ |
而客户端在之后对这个服务器再发送请求时,会从浏览器中取出 Cookie 信息并通过请求报头中的cookie字段发送给服务器
1 | cookie: KLBRSID=abcdefghijk |
7.Https的实现、加密方式、认证
HTTP 有以下安全性问题
- 使用明文进行通信,内容可能会被窃听
- 不验证通信方的身份,通信方的身份有可能遭遇伪装
- 无法证明报文的完整性,报文有可能遭篡改
因此现在常用的是HTTPS协议,HTTPS 并不是新协议,而是让 HTTP 先和 SSL通信,再由 SSL 和 TCP 通信,也就是说 HTTPS 使用了隧道进行通信。
通过使用 SSL,HTTPS 具有了加密(防窃听)、认证(防伪装)和完整性保护(防篡改)
加密
对称密钥加密:加密和解密使用同一个密钥。
- 优点:运算速度快
- 缺点:无法安全地将密钥传输给通信方。
非对称密钥加密 :加密和解密使用不同的密钥。 公开密钥所有人都可以获得,通信发送方获得接收方的公开密钥之后,就可以使用公开密钥进行加密,接收方收到通信内容后使用私有密钥解密。
- 优点:可以更安全地将公开密钥传输给通信发送方
- 缺点:运算速度慢
HTTPS 采用混合的加密机制,使用非对称密钥加密方式,传输对称密钥加密方式所需要的 Secret Key,从而保证安全性; 获取到 Secret Key 后,再使用对称密钥加密方式进行通信,从而保证效率。
认证
HTTPS通过使用证书来对通信方进行认证。 数字证书认证机构CA是客户端与服务器双方都可信赖的第三方机构。 服务器的运营人员向 CA 提出公开密钥的申请,CA 在判明提出申请者的身份后,会对已申请的公开密钥做数字签名,然后分配这个已签名的公开密钥,并将该公开密钥放入公开密钥证书后绑定在一起。 进行 HTTPS 通信时,服务器会把证书发送给客户端。客户端取得其中的公开密钥后,先使用数字签名进行验证,如果验证通过即可开始通信。(比如在访问一些网站时,其没有证书或证书无效浏览器都会提示其不受信任)
完整性保护
SSL 提供报文摘要功能来进行完整性保护。 HTTP 也提供了 MD5 报文摘要功能,但不是安全的。例如报文内容被篡改之后,同时重新计算 MD5 的值,通信接收方是无法意识到发生了篡改。 HTTPS 的报文摘要功能之所以安全,是因为它结合了加密和认证这两个操作。试想一下,加密之后的报文,遭到篡改之后,也很难重新计算报文摘要,因为无法轻易获取明文
HTTPS缺点
- 因为需要进行加密解密等过程,因此速度会更慢
- 需要支付证书授权的高额费用。
8.TCP与UDP的区别
- 连接:TCP是面向连接,UDP是无连接的
- 可靠性:TCP提供可靠的服务,通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达,UDP尽最大努力交付,不保证可靠交付
- 传输:TCP面向字节流,把数据看成一连串无结构的字节流,UDP是面向报文的
- 用途:TCP可用于文件传输、邮件发送,UDP用于网络电话、视频直播等
- 通信方式:TCP连接只能是点到点的,UDP支持一对一,一对多,多对一和多对多的交互通信
- 开销:TCP首部开销20字节,也有各种确保可靠的机制,UDP的首部开销小,只有8个字节
9.GET与POST的区别
- GET在浏览器刷新时是没变化的,而POST会再次提交请求
- GET的参数是有长度、大小限制的,通过URL传递,而POST没有限制,放在请求正文中
- GET请求只能进行url编码,而POST支持多种编码方式
- GET请求参数会保留在浏览器历史记录,超链接的请求方式是GET,而POST中的参数不会被保留
10.流量控制和拥塞控制的区别
拥塞控制的任务是确保子网能够承载所到达的流量,这是一个全局性问题,涉及到各方面的行为,包括所有的主机、所有的路由器、路由器内部的存储转发处理过程,以及所有可能会削弱子网承载容量的其它因素。
而流量控制只与特定的发送方和特定的接收方之间的点到点流量有关,它的任务是确保一个快速的发送方不会持续地以超过接收方吸收能力的速率传输数据
11.301、302的区别
- 含义:301为永久重定向,302为临时重定向
- 效果:301会使搜索引擎在抓取新内容的同时将旧的网址替换为重定向后的网址,而302会使搜索引擎会抓取新的内容却保留旧的网址。
- 适用场景:
- 301:适合域名切换、HTTP迁移到HTTPS
- 302:未登录用户访问个人中心时重定向到登录页面、404页面提示后跳转到首页
- 尽量采用301的原因:采用302重定向会存在网址劫持的问题。网址劫持比如从网站A(网站比较烂)上做了一个302跳转到网站B(搜索排名很靠前),这时候搜索引擎会使用网站B的内容,但却收录了网站A的地址,这样在不知不觉间,网站B在为网站A作贡献,网站A的排名就靠前了。
12.HTTPS全过程
建立HTTPS连接前
- 服务器生成用于非对称加密的公钥和私钥
- 服务器向CA机构进行通信,将公钥交给CA机构
- CA对公钥进行数字签名,生成一个数字签名,将两者绑定在一起公钥证书
- 公钥证书返回并且存在服务器,保存
客户端服务端通信过程
- 客户端发送请求到服务器端
- 服务器端返回证书和公开密钥,公开密钥作为证书的一部分而存在
- 客户端验证证书和公开密钥的有效性,如果有效,则生成共享密钥并使用公开密钥加密发送到服务器端
- 服务器端使用私有密钥解密数据,并使用收到的共享密钥加密数据,发送到客户端
- 此时客户端和服务器都保存了同一个共享密钥,并且可以有效保证是安全的,以后的http传输的主体部分,通过共享密钥进行加密来确保通信过程的安全,报文没有没篡改。
- 客户端使用共享密钥解密数据
- SSL加密建立
SSL认证建立在服务器与客户端的所有通信之前,SSL认证之后http主体部分通过共享密钥加密。
13.HTTP请求性能影响因素
影响HTTP网络请求的因素主要有:带宽和延迟
- 带宽:拨号上网阶段时带宽可能是一个比较严重影响HTTP请求的问题,但现在网络基础建设的完善已经使得带宽得到极大的提升,问题主要在延迟上了。
- 延迟:
- 浏览器阻塞:浏览器会因为一些原因阻塞请求。比如浏览器对于同一个域名,同时只能有 4 个连接(情况会根据浏览器内核不同可能会有所差异),超过浏览器最大连接数限制,后续请求就会被阻塞。
- DNS 查询:浏览器需要知道目标服务器的 IP 才能建立连接。将域名解析为 IP 的这个系统就是DNS,利用DNS缓存域名对应IP可以减少查询时间
- 建立连接:HTTP是基于TCP协议的,浏览器最快也要在第三次握手时才能捎带 HTTP 请求报文,达到真正的建立连接,但是这些连接无法复用会导致每次请求都经历三次握手和慢启动。三次握手在高延迟的场景下影响较明显,慢启动则对文件类大请求影响较大。
14.HTTP1.0和HTTP1.1区别
- 缓存处理:在HTTP1.0中主要使用header里的
If-Modified-Since
,Expires
来做为缓存判断的标准;HTTP1.1则引入了更多的缓存控制策略如If-Unmodified-Since
、If-Match
、If-None-Match
- 带宽优化及网络连接的使用:HTTP1.0存在一些浪费带宽的现象,如客户端只需要对象的一部分内容而服务器却将整个对象传送过来,并且不支持断点续传功能;而HTTP1.1则在请求头引入了range头域,它允许只请求资源的某个部分(状态码为206),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
- 错误通知的管理:HTTP1.1中新增了24个错误状态响应码,如409表示请求的资源与资源的当前状态发生冲突;410表示服务器上的某个资源被永久性的删除。
- Host头处理:在HTTP1.0中认为每台服务器都绑定一个唯一的IP地址,因此,请求消息中的URL并没有传递主机名(hostname)。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个IP地址。HTTP1.1的请求消息和响应消息都应支持Host头域,且请求消息中如果没有Host头域会报告一个错误(400 Bad Request)。
- 长连接:HTTP1.0默认采用短连接,每次请求都需要创建连接;而HTTP 1.1支持长连接和请求的流水线处理,在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟
15.HTTP2.0和HTTP1.X区别
- 新的二进制格式:HTTP1.X的解析是基于文本,基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多;HTTP2.0的协议采用二进制格式,实现方便且健壮。
- 多路复用:连接共享,即每一个HTTP请求都是是用作连接共享机制的。一个请求对应一个ID,这样在一个连接上可以有多个请求,每个连接的请求可以随机的混杂在一起(这里混杂是指HTTP2.0中请求可以在连接中并行执行,而HTTP1.X中请求需要串行化单线程处理,一旦有某个请求超时,后续请求就只能被阻塞),接收方可以根据请求ID将请求再归属到各自不同的服务端请求里面。
- header压缩:HTTP1.X的header中带有大量信息且每次都要重复发送;HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自缓存一份header fields表,既避免了header的重复传输,又减小了需要传输的大小
- 服务端推送:服务端推送能把客户端所需要的资源伴随着index.html一起发送到客户端,这样就省去了客户端重复请求的步骤。正因为没有发起请求,建立连接等操作,所以静态资源通过服务端推送的方式可以极大地提升速度
HTTP2.0完全兼容HTTP1.x的语义,对于不支持HTTP2.0的浏览器,nginx会自动向下兼容
16.WebSocket协议
概念:WebSocket,一种在单个TCP 连接上进行全双工通讯的协议。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。WebSocket 是独立的、创建在 TCP 上的协议。 Websocket 通过 HTTP/1.1 协议的101状态码进行握手。 为了创建Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为握手,而浏览器和服务器只需要完成一次握手,就可以创建持久性连接进行双向数据传输
使用:HTML5定义WebSocket协议是为了更好地节省服务器资源和带宽,并能更实时地通讯。 Websocket使用ws或wss(类似于HTTPS,表示在TLS之上的Websocket)的统一资源标志符。如ws://example.com/api
wss://example.com/
。Websocket使用和 HTTP 相同的 TCP 端口,可以绕过大多数防火墙的限制。默认情况下,Websocket协议使用80端口;运行在TLS之上时,默认使用443端口。
优点
- 开销小:在连接创建后,服务器和客户端间交换数据时用于协议控制的数据包头部相对较小。
- 实时性强:太也同样WebSocket协议是全双工的,服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少
- 保持状态:与HTTP不同,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)
- 更好支持二进制支持:Websocket定义了二进制帧,相较于HTTP可以更轻松地处理二进制内容。
- 支持扩展:Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。
17.浏览器缓存机制详解
对于一个HTTP请求来说,可以分为发起网络HTTP请求、后端处理、接收浏览器HTTP响应三个步骤。浏览器缓存可以帮助在第一和第三步骤中优化性能。如直接使用缓存而不发起HTTP请求、发起请求但后端存储的数据和前端一致则无需进行HTTP响应
缓存过程:浏览器与服务器通信的方式为应答模式。首先由浏览器发起HTTP请求,先去浏览器缓存中查找该请求的结果以及缓存标识,没有则向当服务器发送请求,服务器处理后响应该请求,当浏览器从服务器拿到响应结果后,会根据响应报文中HTTP头的缓存标识,决定是否缓存结果,是则将请求结果和缓存标识存入浏览器缓存中以供下次查询。
缓存位置:共分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络
- Service Worker:浏览器缓存。采用HTTPS传输协议,实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,在下次用户访问时通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。
- Memory Cache:内存缓存。读取高效但持续性很短,会随着进程结束而释放(关闭浏览器页面就会释放内存缓存),内存缓存的内容主要是页面中已经抓取到的CSS、JS、图片资源等等
- Disk Cache:硬盘缓存。读取缓慢但持续性长。其会根据 HTTP请求头中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。
- Push Cache:推送缓存。推送缓存作为HTTP2.0的功能,当以上三种缓存都没有命中时,它才会被使用。它只在会话中存在,一旦会话结束就被释放,并且持续性也很短暂
如果以上四种缓存都没有命中,那就只能发起HTTP请求来获取资源。
缓存策略
强缓存:不会向服务器发送请求,直接从缓存中读取资源。强缓存可以通过设置HTTP请求头的
expires
和cache-control
来实现expires
:缓存过期时间。用来指定资源到期的时间,是服务器端的具体的时间点。expires
是HTTP响应消息头中的字段,用于告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据而无需再次请求。expires
是 HTTP1.0的产物,受限于本地时间,如果修改了本地时间可能会造成缓存失效cache-control
:在HTTP1.1中,cache-control
主要用于控制网页缓存。比如cache-Control:max-age=300
时代表在这个请求正确返回时间的5分钟内再次加载资源,就会命中强缓存。cache-control
可以在请求头或者响应头中设置,并且可以组合使用多种指令如下public
:所有内容都将被缓存(客户端和代理服务器都可缓存)private
:所有内容只有客户端可以缓存(默认设置)no-cache
:客户端缓存内容,是否使用缓存则需要经过协商缓存来验证决定no-store
:所有内容都不会被缓存,即不使用强制缓存和协商缓存max-age
:max-age=xxx (xxx is numeric)表示缓存内容将在xxx秒后失效
协商缓存:协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程。协商缓存可以通过设置HTTP请求头的
last-modified
和ETag
来实现。使用协商缓存的两种结果分别为协商缓存生效,返回304和Not Modified和协商缓存失效,返回200和请求结果。使用协商缓存的实现方式如下last-modified
和if-modified-since
:浏览器在第一次访问资源时,服务器返回资源的同时,在响应报头中添加last-modified
的值,表示资源在服务器上的最后修改时间;当浏览器下一次请求这个资源时,会检测到有last-modified
这个消息头,并添加if-modified-since
消息头,该值对应last-modified
的值;当服务器再次收到这个资源请求,会根据if-modified-since
的值与服务器中这个资源的最后修改时间相比较,如果没有变化,会返回状态码304和空响应体,让浏览器直接从缓存中读取。如果if-modified-since
时间早于服务器中该资源的最后修改时间,则说明资源发生更新,就返回新的资源文件和状态码200ETag
和if-none-match
:Etag
是服务器发送响应时,返回当前资源文件的一个唯一标识(由服务器生成),只要资源有变化,Etag
就会重新生成。浏览器在下一次加载资源向服务器发送请求时,会将上一次返回的Etag
值放到请求头的if-none-match
里,服务器只需要比较客户端传来的if-none-match
跟自己服务器上该资源的ETag
是否一致,就能判断资源相对客户端而言是否被修改过。如果服务器发现ETag
匹配不上则会GET 200回包形式将新的资源(包括新ETag
信息)发给客户端,如果ETag
一致,则直接返回304并让客户端直接使用本地缓存
18.实际场景应用缓存策略
- 频繁变动的资源:如果资源频繁变动,首先应使用
cache-control: no-cache
来使浏览器请求服务器时选择协商缓存策略,然后配合ETag
或者last-modified
来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。 - 不常变化的资源:通常在处理这类资源时,可以给响应头的
cache-control
配置一个比较大的参数,比如max-age=31536000
(一年缓存才能时间),这样浏览器之后请求相同的URL会采用强制缓存命中。而如果资源发生更新时则只需要在文件名(或者路径)中添加 hash、版本号等可以动态生成的字符,从而达到更改引用 URL的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。在线提供的类库 (如jquery-3.3.1.min.js
,lodash.min.js
等) 均采用这个模式。
用户对浏览器缓存的影响:用户在操作浏览器时会触发相应的缓存策略
- 在地址栏输入地址打开网页:查找磁盘缓存中是否有匹配。有则使用,没有就发送网络请求
- 普通刷新 (F5):因为网页tab并没有关闭,因此内存缓存可用时会被优先使用,其次才是磁盘缓存
- 强制刷新 (Ctrl + F5):浏览器不使用缓存,因此发送的请求头部均带有
Cache-control: no-cache
(为了兼容,还带了Pragma: no-cache
),服务器直接返回 200 和最新资源文件。
19.content-type
详解
content-type
:即Internet Media Type,互联网媒体类型,也叫MIME(Multipurpose Internet Mail Extensions)类型,是HTTP响应报头中的一项参数。HTTP在传输数据对象时会为数据标识上MIME的格式标签,用于区分不同数据类型。最初MIME只是用于电子邮件系统的,后来HTTP也采用了这一方案。content-type
用来表示请求和响应中的媒体类型信息。它用来告诉服务端如何处理请求的数据,以及告诉客户端如何解析响应的数据,比如显示图片,解析并展示html等等。
格式:content-type:type/subtype ;parameter
type
:主类型,任意的字符串,如text
,如果是*
号代表所有类型subtype
:子类型,任意的字符串,如html
,如果是*
号代表所有类型,用/
与主类型隔开parameter
:可选参数,如charset
,boundary
等。
示例:content-type: text/html; content-type: application/json;charset:utf-8;
content-type
数据类型
- HTML文档标记:
text/html;
- 普通ASCII文档标记:
text/html;
- JPEG图片标记:
image/jpeg;
- GIF图片标记:
image/gif;
- JS文档标记:
application/javascript;
- XML文件标记:
application/xml;
- JSON文件标记:
application/json;
- 多部分多媒体文件标记:
multipart/form-data
使用
客户端:客户端发送请求时要注意
content-type
数据类型的设置,设置不当可能导致请求失败。比如在Spring中接口用到@RequestBody
注解,将会自动将请求实体内容解析成bean对象,但前提是客户端发送过来请求的content-type
需要设置成application/json
,否则会出现415错误(不支持媒体类型)- 客户端请求访问RESTful接口(用AJAX方式使用json格式参数),将
content-type
设置为application/json; charset=UTF-8;
- 客户端请求将上传文件,将
content-type
设置为multipart/form-data
- 客户端进行普通表单提交,将
content-type
设置为application/x-www-form-urlencoded
- 客户端请求访问RESTful接口(用AJAX方式使用json格式参数),将
服务端:服务端发送响应时也要注意
content-type
的设置,虽然前端解析响应时不会根据content-type
,服务端也会自动设置准确的content-type
,但是如果手动修改成错误设置就可能导致问题,比如在Spring项目用@ResponseBody
,Spring会将content-type
设成application/json;charset=UTF-8;
,如果在该方法中实现文件下载功能就会失败,因此在文件下载时要将content-type
设置为multipart/form-data
,并且将content-disposition
设置为attachment;fileName=文件.后缀
20.Nagle算法
存在问题:在使用一些协议通讯(如Telnet)的时候,会有一个字节字节地发送数据的情景,当每次发送一个字节的有用数据,就会产生41个字节(20字节的IP协议首部和20字节的TCP首部)长的分组,这就导致了要传输1个有用字节数据却要额外传输掉40个字节的首部信息,这个开销是很大的,为了解决这个问题就有了Nagle算法
Nagle算法主要是避免发送小的数据包,要求TCP连接上最多只能有一个未被确认的小分组,在该分组的确认到达之前不能发送其他的小分组。相反,TCP会收集这些少量的小分组,并在确认到来时以一个分组的方式发出去。采用Nagle算法规则如下
- 如果包长度大于等于最大报文段(MSS),则直接发送
- 如果包带有FIN标志,则直接发送
- 如果设置了TCP_NODELAY选项,则直接发送
- 如果包长度小于最大报文段,且当前还有未被确认(未收到ACK标识的响应)的数据在缓冲区内,则先将待发送数据放到buffer中等到前面已发出去的小数据包都被确认后再发送,这样是为了保证一个TCP连接上最多只有一个未被确认的小分组,从而防止拥塞出现
- 如果上面条件均满足但超时,则直接发送
nagle算法是默认开启的,但可以通过设置TCP_NODELAY套接字选项来关闭
21.跨域资源共享
同源:两个URL中的域名、协议、端口均相同即为同源,有一个不同就是非同源。同源策略是浏览器最核心最基础的安全策略,WEB构建在同源策略的基础之上,浏览器对非同源脚本的限制措施是对同源策略的具体实现。但同源不是绝对的,像HTML中<script>
、 <img>
、<video>
、<iframe>
、<link>
等带有src属性的标签以及Ajax请求都可以从不同的域加载和执行资源。跨域请求就是指当前发起请求的域与该请求指向的资源所在的域不一样(非同源)
同源限制策略:为了保证安全(防止CSRF攻击等),浏览器会对跨域请求作出一些限制策略。比如限制不同源的Document
对象或 JS
脚本对当前Document
对象进行读取或设置属性;禁止使用AJAX直接发起跨域HTTP请求(可以发送请求但会被浏览器拦截);使用AJAX请求不能携带与本网站不同源的Cookie
JSONP:JSONP是一种非官方的跨域数据交互协议,其本质上是利用<script>
、<img>
等标签不受同源策略限制可以从不同域加载并执行资源的特性,来实现数据跨域传输。JSONP的实现主要依赖于回调函数和服务器数据。回调函数是指在浏览器端注册一个能接受参数并处理的callback回调函数,然后借助<script>
标签把非同源地址+回调函数名设为src参数,目的是让服务端得到回调函数。服务端数据是由服务端接受到<script>
请求后,获取回调函数名并填充参数,最后返回一段带有参数的JS代码给浏览器,形如callback({name:'jack'});
,浏览器获得后在本地执行回调函数即可实现不同域的资源处理。不过要注意JSONP只支持 GET 请求,有局限性
跨域资源共享:CORS,Cross-origin resource sharing。 它允许浏览器向跨源服务器发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。CORS需要浏览器和服务器同时支持。整个CORS通信过程,都由浏览器自动完成。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。因此实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。浏览器将CORS请求分成两类:简单请求和非简单请求。
- 简单请求:同时满足以下条件。使用简单跨域请求时浏览器会自动在请求的头信息加上
Origin
字段,表示本次请求来自哪个源(协议 + 域名 + 端口),服务端会获取到这个值,然后判断是否同意这次请求并返回。- 请求方法是
HEAD
、GET
、POST
中的一种 - HTTP的头信息只包含
Accept
、Accept-Language
、Content-Language
、Last-Event-ID
、content-type
(只限application/x-www-form-urlencoded
、multipart/form-data
、text/plain
三个值)这些字段
- 请求方法是
- 非简单请求:凡是不同时满足上面的条件,就属于非简单请求。使用非简单请求,浏览器需要先使用
OPTION
方式发起一个预检请求,先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。在服务器确定允许后,才发起实际的跨域请求,否则报错。发送预检请求还会在请求头加上Access-Control-Request-Method
、Access-Control-Request-Headers
两个字段来表示预检请求的HTTP方式和首部
CORS与 JSONP 的区别
- JSONP只能实现GET请求,而CORS支持所有类型的HTTP请求
- CORS可以是通过普通的XMLHttpRequest发起请求和获取数据,比起JSONP 有容易处理错误
- 虽然绝大多数现代的浏览器都已经支持 CORS,但是 CORS 的兼容性比不上 JSONP,一些比较老的浏览器还是只支持 JSONP
22.短网址原理
短网址:由长网址缩短成一个很短的网址,用户在访问这个短网址时可以重定向到原本的长网址。使用短网址的优点有节省网址长度更易于传播、规避域名屏蔽手段、隐藏真实地址等等,短网址可应用于发布有字数限制的微博、二维码,付费推广链接等
原理:当在浏览器里输入短网址时,DNS首先解析获得短链的IP地址(一般是企业服务如新浪http://t.cn
、百度http://dwz.cn
)部分,当DNS获得 IP 地址以后,会向这个地址发送GET方式的HTTP请求,查询短链的短码部分,而后服务器会通过短码获取对应的长网址请求通过HTTP请求以301状态码来转到对应的长网址去
301 是永久重定向,302 是临时重定向。短地址一经生成就不会变化,所以这里才有用301是符合HTTP语义的,同时服务器压力也会有一定减少。
而将长网址转换成短网址需要借助生成算法,比较流行的算法有自增序列算法和摘要算法
- 自增序列算法:也叫永不重复算法。设置ID自增,一个10进制ID对应一个62进制的数值,实现一对一关系,这样也就不会出现重复的情况。这个算法比较好理解,就是利用低进制转高进制时字符数会减少的特性
- 摘要算法:将长网址通过
md5
生成32位签名串,分为4段每段8个字节,对这四段循环处理,循环时将每段看成16进制串,与0x3fffffff(30位1)进行与操作,保留得到的30位,将30位数再分成6段每段5位,每段的数字作为字母表的索引得到对应字符,依次进行获得6个字符的字符串。这样总的md5
串可以获得4个6位字符串,取其中任意一个就可作为这长网址对应的短网址,不过这种算法会生成4个短网址,但仍然存在重复几率
23.扫描二维码实现登录的全过程
二维码:一种编码方式,比传统条形码(条形码只能表示一串数字)能存储更多的信息,也能表示更多的数据类型:如字符、数字、图片、文件等,多用于移动设备。
基于token认证机制:基于token认证不同于传统的账号密码认证,安全性要高很多。流程:
- 第一次登录时需要输入账号密码,后续再登录则不再需要输入账号密码(因为在登陆时不仅传入账号、密码,还传入了移动设备的相关信息)
- 服务端第一次验证时,在校验账号、密码正确后,服务端会将账号与设备关联起来,并且生成一个 token令牌,该token令牌会与账号、设备关联,类似于键值对,token令牌作为 key,账号、设备信息作为value持久化在服务器上
- 服务端将token令牌返回给移动端
- 移动端将token存储在本地
- 之后移动端都通过token来访问服务端 API ,当然除了token还需要携带设备信息,因为 token可能会被劫持(带上设备信息之后,就算 token 被劫持也没有关系,因为设备信息是唯一的)
二维码扫码登录过程
- 待扫描阶段:首先由电脑端携带设备信息向服务端发起生成二维码请求,服务端会生成唯一的二维码 ID(该二维码ID与电脑设备相关联)。而在电脑上这个二维码ID会以二维码的形式展示出来,等待移动端扫码。此时在电脑端还会启动一个定时器,来轮询二维码的状态。如果一段时间内移动端都未扫描,则二维码将会失效
- 已扫描待确认阶段:使用手机扫码电脑上的二维码后,电脑端的二维码会显示”已扫码,请在手机端确认“。这个阶段是移动端跟服务端交互的过程。首先移动端扫描二维码,获取二维码 ID,然后将移动端用于登录的token和这个二维码ID 作为参数发送给服务端(此时需要移动端账号一定是登录状态),在服务端接受请求后,会将移动端token与二维码ID关联生成出一个临时token,并返回给移动端,此时电脑端轮询的定时器会检测到二维码状态已经发生变化,显示”已扫描,请确认“
- 已确认:移动端携带上一步骤中获取的临时 token确认登录,服务端在校对完成后会更新二维码状态,并且给 电脑端返回一个正式token ,后续电脑端就是通过这个正式token来访问服务端。此时电脑端轮询的定时器检测到二维码状态为登录状态,显示”已确认“,并且完成电脑端登录,后续电脑端的访问也都将基于token完成
分布式、中间件
1.Docker优势与适用场景
Docker 主要解决环境配置问题,它是一种虚拟化技术,对进程进行隔离,被隔离的进程独立于宿主操作系统和其它隔离的进程。使用 Docker 可以不修改应用程序代码,不需要开发人员学习特定环境下的技术,就能够将现有的应用程序部署在其它机器上。
优势
- 启动速度快、占用资源少
- 易于迁移。能提供一致性的运行环境。已经打包好的应用可以在不同的机器上进行迁移,而不用担心环境变化导致无法运行。
- 易于维护。使用分层技术和镜像,使得应用可以更容易复用重复的部分。复用程度越高,维护工作也越容易。
- 易于扩展。可以使用基础镜像进一步扩展得到新的镜像,并且官方和开源社区提供了大量的镜像,通过扩展这些镜像可以非常容易得到我们想要的镜像。
适用场景
- 持续集成:持续集成指的是频繁地将代码集成到主干上,这样能够更快地发现错误。 Docker 具有轻量级以及隔离性的特点,在将代码集成到一个 Docker 中不会对其它 Docker 产生影响。
- 提供可伸缩的云服务:根据应用的负载情况,可以很容易地增加或者减少 Docker。
- 搭建微服务架构:Docker 轻量级的特点使得它很适合用于部署、维护、组合微服务。
Docker的镜像是一种静态结构,包含着容器运行时所需要的代码以及其它组件,而容器是镜像的一个实例。
2.CAP理论
CAP分别指的是一致性(C:Consistency)、可用性(A:Availability)和分区容忍性(P:Partition Tolerance)。分布式系统不可能同时最多只能同时满足其中两项
- 一致性:一致性指的是分布的多个数据副本是否能保持一致的特性,在一致性的条件下,系统在执行数据更新操作之后能够从一致性状态转移到另一个一致性状态。 对系统的一个数据更新成功之后,如果所有用户都能够读取到最新的值,该系统就被认为具有强一致性。
- 可用性:可用性指分布式系统在面对各种异常时可以提供正常服务的能力,可以用系统可用时间占总时间的比值来衡量。 在可用性条件下,要求系统提供的服务一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。
- 分区容忍性:一个分布式系统里面节点组成的网络本来应该是连通的,但可能因为一些故障使得有些节点之间不连通,整个网络就分成了几块区域。数据就散布在了这些不连通的区域中,这就叫分区。当一个数据项只在一个节点中保存,那么分区出现后,和这个节点不连通的部分就访问不到这个数据了,这时分区就是无法容忍的。实现分区容忍性的办法就是一个数据项复制到多个节点上,但因此也就会衍生出一致性和可用性的问题
- 三者权衡:在分布式系统中,分区容忍性必不可少,因为需要总是假设网络是不可靠的。因此,CAP 理论实际上是要在可用性和一致性之间做权衡。 可用性和一致性往往是冲突的,很难使它们同时满足。在多个节点之间进行数据同步时, 为了保证一致性(CP),不能访问未同步完成的节点,也就失去了部分可用性; 为了保证可用性(AP),允许读取所有节点的数据,但是数据可能不一致。
3.BASE理论
BASE分别指基本可用(Basically Available)、软状态(Soft State)和最终一致性(Eventually Consistent)。BASE 理论是对 CAP 中一致性和可用性权衡的结果,核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
- 基本可用:指分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。 例如电商在做促销时,为了保证购物系统的稳定性,部分消费者可能会被引导到一个降级的页面。
- 软状态:指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在时延。
- 最终一致性:最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态。 ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中,如消息队列。
4.常用的分布式锁
在单机场景下,可以使用语言的内置锁来实现进程同步。但是在分布式场景下,需要同步的进程可能位于不同的节点上,那么就需要使用分布式锁。 阻塞锁通常使用互斥量来实现: 互斥量为 0 表示有其它进程在使用锁,此时处于锁定状态,互斥量为1表示未锁定状态。 1 和 0 可以用一个整型值表示也可以用某个数据是否存在表示。
数据库的唯一索引 :获得锁时向表中插入一条记录,释放锁时删除这条记录。唯一索引可以保证该记录只被插入一次,那么就可以用这个记录是否存在来判断是否处于锁定状态。 存在以下几个问题:
- 锁没有失效时间,解锁失败的话其它进程无法再获得该锁
- 只能是非阻塞锁,插入失败直接就报错了,无法重试
- 不可重入,已经获得锁的进程也必须重新获取锁
Redis的setnx指令:使用setnx(set if not exist)指令插入一个键值对,如果 Key 已经存在,那么会返回 False,否则插入成功并返回 True。setnx指令和数据库的唯一索引类似,保证了只存在一个 Key 的键值对,那么可以用一个 Key 的键值对是否存在来判断是否存于锁定状态。expire指令可以为一个键值对设置一个过期时间,从而避免了数据库唯一索引实现方式中释放锁失败的问题。
Zookeeper 的有序节点:创建一个锁目录; 当一个客户端需要获取锁时,在该目录下创建临时且有序的子节点,客户端获取锁目录下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁; 获得锁后执行业务代码,并在完成后删除对应的子节点。 因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。这种实现方式也不会出现数据库的唯一索引实现方式释放锁失败的问题。
5.分布式事务
分布式事务:指事务的操作位于不同的节点上,需要保证事务的 ACID 特性
两阶段提交:通过引入协调者来协调参与者的行为,并最终决定这些参与者是否要真正执行事务。首先由协调者询问参与者事务是否执行成功,参与者发回事务执行结果。询问可以看成一种投票,只有每个参与者都同意才能执行。 如果事务在每个参与者上都执行成功,事务协调者发送通知让每个参与者提交事务,否则协调者发送通知让每个参与者回滚事务
存在问题
- 同步阻塞:所有事务参与者在等待其它参与者响应的时候都处于同步阻塞等待状态,无法进行其它操作
- 单点问题: 协调者在2PC中起到非常大的作用,发生故障将会造成很大影响。特别是在提交阶段发生故障,所有参与者会一直同步阻塞等待,无法完成其它操作
- 数据不一致:在提交阶段,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致
- 太过保守:任意一个节点失败就会导致整个事务失败,没有完善的容错机制
本地消息表:本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。之后将本地消息表中的消息转发到消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发。在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。
6.CAP理论的应用
当向注册中心查询服务列表时,可以容忍注册中心返回的是几分钟以前的注册信息,但不能接受服务直接那宕掉不可用。此时要求服务注册功能对可用性的要求要高于一致性,即对应AP。
zookeeper保证CP:zookeeper会在master节点因为网络故障与其他节点失去联系时,剩余的节点重新进行leader选举。但问题在于选举leader的时间太长,且选举期间整个zookeeper集群都是不可用的,这就导致在选举期间注册服务瘫痪。在云部署的环境下,因网络问题使得zk集群失去master节点是较大概率会发生的事,虽然服务能够最终恢复,但是漫长的选举时间导致的注册长期不可用是不能容忍的。
Eureka保证AP:Eureka在设计时则优先保证可用性。Eureka的各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而Eureka的客户端在向某个Eureka注册时如果发现连接失败,则会自动切换至其它节点,只要有一台Eureka还在,就能保证注册服务可用(保证可用性),只不过查到的信息可能不是最新的(不保证强一致性)。
Redis保证AP:在单机情况下,Redis能实现CP,但在分布式情况下,Redis采用集群部署,其中涉及到使用主仆模式就有点类似于选举,来保证AP
7.Git与SVN的区别
- Git 属于分布式版本控制系统,而 SVN 属于集中式。
- 集中式版本控制只有中心服务器拥有一份代码,而分布式版本控制每个人的电脑上就有一份完整的代码。
- 集中式版本控制有安全性问题,当中心服务器挂了所有人都没法工作。
- 集中式版本控制需要联网才能工作,如果网速过慢,那么提交一个文件会慢的无法让人忍受。而分布式版本控制不需要连网就能工作。 分布式版本控制新建分支、合并分支操作速度非常快,而集中式版本控制新建一个分支相当于复制一份完整代码。
Git需要中心服务器来交换每个用户的修改,没有中心服务器时也能工作,但是中心服务器能够 24 小时保持开机状态,这样就能更方便的交换修改。Github 就是一个中心服务器。
8.消息队列的模型以及使用场景
消息队列模型
- 点对点消息:生产者向消息队列中发送了一个消息之后,只能被一个消费者消费一次。
- 发布/订阅消息:生产者向频道发送一个消息之后,多个消费者可以从该频道订阅到这条消息并消费。发布/订阅模式和观察者模式有以下不同:
- 观察者模式中,观察者和主题都知道对方的存在,而在发布与订阅模式中,生产者与消费者不知道对方的存在,它们之间通过频道进行通信
- 观察者模式是同步的,当事件触发时,主题会调用观察者的方法,然后等待方法返回;而发布与订阅模式是异步的,生产者向频道发送一个消息之后,就不需要关心消费者何时去订阅这个消息,可以立即返回。
使用场景
- 异步处理:消息发送者将消息发送给消息队列之后,不需要同步等待消息接收者处理完毕,而是立即返回进行其它操作,消息接收者从消息队列中订阅消息之后异步处理。
- 流量削锋:在高并发的场景下,如果短时间有大量的请求到达会压垮服务器。可以将请求发送到消息队列中,服务器按照其处理能力从消息队列中订阅消息进行处理。
- 应用解耦:如果模块之间不直接进行调用,模块之间耦合度就会很低,那么修改一个模块或者新增一个模块对其它模块的影响会很小,从而实现可扩展性。通过使用消息队列,一个模块只需要向消息队列中发送消息,其它模块可以选择性地从消息队列中订阅消息从而完成调用。
9.如何保证消息队列的可靠性
发送端的可靠性:发送端完成操作后一定能将消息成功发送到消息队列中
实现方法:在本地数据库建一张消息表,将消息数据与业务数据保存在同一数据库实例里,这样就可以利用本地数据库的事务机制。事务提交成功后,将消息表中的消息转移到消息队列中,若转移消息成功则删除消息表中的数据,否则继续重传。
接收端的可靠性:接收端能够从消息队列成功消费一次消息
实现方法:
- 保证接收端处理消息的业务逻辑具有幂等性,只要具有幂等性,消费多少次消息最后处理的结果都是一样的
- 保证消息具有唯一编号,并使用一张日志表来记录已经消费的消息编号。
10.Dubbo协议有哪些
- dubbo协议:作为Dubbo框架的默认协议
- 连接个数:单连接
- 连接方式:长连接
- 传输协议:TCP
- 传输方式:NIO异步传输
- 序列化:Hessian二进制序列化
- 适用范围:传入传出参数数据包较小(建议小于100K),消费者比提供者个数多,单一消费者无法压满提供者,尽量不要用dubbo协议传输大文件或超大字符串。
- 适用场景:常规远程服务方法调用
- rmi协议:Java标准的远程调用协议,采用JDK标准实现,采用阻塞式短连接和JDK标准序列化方式
- 连接个数:多连接
- 连接方式:短连接
- 传输协议:TCP
- 传输方式:同步传输
- 序列化:Java标准二进制序列化
- 适用范围:传入传出参数数据包大小混合,消费者与提供者个数差不多,可传文件。
- 适用场景:常规远程服务方法调用,与原生RMI服务互操作
- hessian协议:基于Hessian的远程调用协议。 Hessian协议用于集成Hessian的服务,Hessian底层采用Http通讯,采用Servlet暴露服务,Dubbo缺省内嵌Jetty作为服务器实现,其通讯效率高于WebService和Java自带的序列化
- 连接个数:多连接
- 连接方式:短连接
- 传输协议:HTTP
- 传输方式:同步传输
- 序列化:表单序列化
- 适用范围:传入传出参数数据包大小混合,提供者比消费者个数多,可用浏览器查看,可用表单或URL传入参数,暂不支持传文件。
- 适用场景:需同时给应用程序和浏览器JS使用的服务
- http协议:基于http表单的远程调用协议。
- 连接个数:多连接
- 连接方式:短连接
- 传输协议:HTTP
- 传输方式:同步传输
- 序列化:表单序列化
- 适用范围:传入传出参数数据包大小混合,提供者比消费者个数多,可用浏览器查看,可用表单或URL传入参数,暂不支持传文件。
- 适用场景:需同时给应用程序和浏览器JS使用的服务
- webservice协议:基于WebService的远程调用协议
- 连接个数:多连接
- 连接方式:短连接
- 传输协议:HTTP
- 传输方式:同步传输
- 序列化:SOAP文本序列化
- 适用场景:系统集成,跨语言调用
11.Dubbo的序列化
序列化是将一个对象变成一个二进制流, 反序列化是将二进制流转换成对象
- 减小内存空间和网络传输的带宽
- 提高分布式的可扩展性
- 实现通用性,使接口可共用。
Dubbo序列化支持java、compactedjava、nativejava、fastjson、dubbo、fst、hessian2、kryo,其中默认hessian2。其中java、compactedjava、nativejava都属于原生java的序列化。
- hessian2序列化:hessian是一种跨语言的高效二进制序列化方式。但这里实际不是原生的hessian2序列化,而是阿里修改过的,它是dubbo RPC默认启用的序列化方式。
- json序列化:目前有两种实现,一种是采用的阿里的fastjson库,另一种是采用dubbo中自己实现的简单json库,但其实现都不是特别成熟,而且json这种文本序列化性能一般不如上面两种二进制序列化。
- java序列化:主要是采用JDK自带的Java序列化实现,性能很不理想
12.Eureka自我保护机制
Eureka自我保护机制:如果 Eureka服务端在 15 分钟内有超过 85% 的 Eureka客户端都没有正常的发送心跳过来(单机模式时尤为明显),那么 Eureka服务端就认为注册中心与客户端出现了网络故障,Eureka 服务端将会自动进入自我保护机制。这也就是为什么平时使用一个 Eureka服务端与一个 Eureka客户端时,即使手动关闭了 Eureka客户端,但是 Eureka服务端主页中“Instances currently registered with Eureka”下面 status 栏仍然显示为 UP。
机制原因:正常情况(15 分钟内没有超过 85% 的Eureka客户端都没有正常的发送心跳)下,如果 Eureka 服务器在约定时间内(默认90秒)没有接收到某个Eureka客户端微服务实例的心跳,Eureka服务端将会移除该实例。但当网络分区故障发生时,大面积(甚至所有)Eureka客户端的微服务都与Eureka服务器之间无法正常通信,而各个微服务本身是能正常运行的,此时注册中心不应该移除这些微服务,所以引入了自我保护机制。
自我保护机制效果:
- Eureka服务端不再从注册列表中移除因为长时间没收到心跳而应该过期的服务
- Eureka服务端仍然能够接受新服务的注册和查询请求,但是不会被同步到其它Eureka服务端上,保证当前节点依然可用。
- 当网络稳定时,当前实例新的注册信息会被同步到其它节点中,因此Eureka可以很好的应对因网络故障导致部分节点失去联系的情况,而不会像zookeeper那样使整个注册服务瘫痪
13.分布式的优缺点
优点
- 垂直或水平拆分业务系统:不同业务系统部署到不同机器上,充分利用多机器性能优势
- 提高系统的高可用性:分布式架构中的一个系统出现问题,可能导致周边部分系统受影响,但不会整个系统崩溃
- 模块重用度高:系统模块重用度高
- 速度提升:将软件服务模块拆分后开发和发布速度可以并行而更快
- 拓展性:对单个模块进行拓展时不需要动整个系统
缺点
- 可靠性不确定:分布式系统是跨进程跨网络的,性能很收网络延迟和带宽的影响。由于高度依赖网络状况,任何一次远程调用都可以失败。随着服务的增多,还会出现更多的潜在故障点。
- 运维成本高:由于一个分布式系统拆成多个服务,每个服务都得配置,部署,监控,日志处理。
- 异步:引入各种中间件,异步通信大大增加了功能实现的复杂度。
- 数据一致性:分布式系统必然会有分布式事务的出现,这时对数据的一致性,需要在C(一致性)A(可用性)P(分区容错性)中做出选择
14.负载均衡算法有哪些
负载均衡用于集群,集群中的应用服务器(节点)通常被设计成无状态,用户可以请求任何一个节点。 负载均衡器会根据集群中每个节点的负载情况,将用户请求转发到合适的节点上。 负载均衡器可以用来实现高可用以及伸缩性
- 高可用性:当某个节点故障时负载均衡器会将用户请求转发到另外的节点上,从而保证所有服务持续可用
- 伸缩性:根据系统整体负载情况,可以很容易地添加或移除节点
负载均衡器运行过程包含两个部分
- 根据负载均衡算法得到转发的节点
- 进行转发
负载均衡算法
- 轮询(Round Robin): 轮询算法把每个请求轮流发送到每个服务器上。该算法比较适合每个服务器的性能差不多的场景,如果有性能存在差异的情况下,那么性能较差的服务器可能无法承担过大的负载
- 加权轮询(Weighted Round Robbin):加权轮询是在轮询的基础上,根据服务器的性能差异,为服务器赋予一定的权值,性能高的服务器分配更高的权值。
- 最少连接(least Connections): 由于每个请求的连接时间不一样,使用轮询或者加权轮询算法的话,可能会让一台服务器当前连接数过大,而另一台服务器的连接过小,造成负载不均衡。 最少连接算法就是将请求发送给当前最少连接数的服务器上
- 加权最少连接(Weighted Least Connection): 在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。
- 随机算法(Random): 把请求随机发送到服务器上。该算法比较适合服务器性能差不多的场景
- 源地址哈希法(IP Hash): 源地址哈希通过对客户端 IP 计算哈希值之后,再对服务器数量取模得到目标服务器的序号。 可以保证同一IP的客户端的请求会转发到同一台服务器上,用来实现会话粘滞
15.为什么要序列化和反序列化
Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程
- 序列化:对象序列化的最主要的用处就是在传递和保存对象的时候,保证对象的完整性和可传递性。序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中。核心作用是对象状态的保存与重建。
- 反序列化:客户端从文件中或网络上获得序列化后的对象字节流,根据字节流中所保存的对象状态及描述信息,通过反序列化重建对象。
序列化的优点
- 实现分布式对象:如RMI(远程方法调用)要利用对象序列化运行远程主机上的服务
- 完整保存数据:java对象序列化不仅保留一个对象的数据,而且可以递归保存对象引用的每个对象的数据。可以将整个对象层次写入字节流中,可以保存在文件中或在网络连接上传递。利用对象序列化可以进行对象的深拷贝,即复制对象本身及引用的对象本身。
- 实现持久化:序列化可以将内存中的类写入文件或数据库中来实现持久化。比如:将一个已经实例化的类转成文件存储,下次需要实例化时只要反序列化即可将类实例化到内存中并保留序列化时类中的所有变量和状态。
- 统一格式:对象、文件、数据有许多不同的格式,很难统一传输和保存。序列化以后就都是字节流了,无论原来是什么东西,都能变成一样的东西,就可以进行通用的格式传输或保存,传输结束以后,要再次使用,就进行反序列化还原,这样对象还是对象,文件还是文件。
16.为什么需要限流
需要限流的原因:
与用户打交道的服务:比如web服务、对外API,这种类型的服务有以下几种可能导致机器被拖垮,这些情况是无法预知的,扩容是根本来不及
- 用户增长过快
- 热点事件
- 竞争对手爬虫
- 恶意刷单
对内RPC服务:一个服务A的接口可能被B、C、D、E多个服务进行调用,在服务B发生突发流量时,直接把服务A给调用挂了,导致服务A对C、D、E也无法提供服务。 这种情况时有发生,解决方案有
- 每个调用方采用线程池进行资源隔离
- 使用限流手段对每个调用方进行限流
17.限流算法
计数器算法:计数器限流实现,一般会限制一秒钟的能够通过的请求数,比如限流QPS为N,算法的实现思路就是从第一个请求进来开始计时,在接下来1s内每个请求会将计数加1,如果累加的数字达到了N则后续的请求就会被全部拒绝。等到1s结束后,把计数恢复成0,重新开始计数。
突刺现象:计数器算法的实现方式存在弊端:如果在单位时间1s内的前10ms,已经通过了N个请求,那后面的990ms,只能把请求拒绝
漏桶算法:为了消除突刺现象,可以采用漏桶算法实现限流,算法的实现思路是内部设置一个容器,类似漏斗,当请求进来时相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。这样不管上面流量多大,下面流出的速度始终保持不变。不管服务调用方多么不稳定,通过漏桶算法进行限流,每10毫秒处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。
算法实现:可以准备一个队列用来保存请求,另外通过一个线程池定时地从队列中获取请求并执行,可以一次性获取多个并发执行
但是漏桶算法在使用后也存在弊端:无法应对短时间的突发流量
令牌桶算法:令牌桶算法算是对漏桶算法的一种改进,漏桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。算法的实现思路是在内部设置一个桶用来存放固定数量的令牌。以一定的速率往桶中放令牌,每次请求调用需要先获取令牌,只有拿到令牌才有机会继续执行,否则选择选择等待可用的令牌或直接拒绝。放令牌这个动作会持续不断地进行,如果桶中令牌数达到上限,就丢弃令牌,所以就存在这种情况,桶中一直有大量的可用令牌,这时进来的请求就可以直接拿到令牌执行,比如设置QPS为100,那么限流器初始化完成一秒后,桶中就已经有100个令牌了,这时服务还没完全启动好,等启动完成对外提供服务时,该限流器可以抵挡瞬时的100个请求。所以,只有桶中没有令牌时,请求才会进行等待,最后相当于以一定的速率执行。
算法实现:可以准备一个队列用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。
18.RPC请求连接方式
RPC请求要实现跨进程的通信,首先要建立连接,一般分为以下3种连接方式:
- 对于每次RPC请求都单独建立一个TCP连接,请求完成后关闭连接,每一个请求都要独占一个TCP连接
- 采用连接池机制,对TCP连接做缓存处理,但每一次请求仍然需要独占一个TCP连接
- 建立一个TCP长连接,每次RPC请求都共享这个连接,通过请求ID来标识返回数据属于哪个请求
19.接口性能问题优化方案
有时候在整合测试时发现一个接口的响应时间很长,此时就需要对该接口进行优化,发现接口性能瓶颈,定位性能瓶颈通常有两个思路:工具监控和经验猜想
工具监控:可以使用arthas,使用trace命令,检测接口后其中耗时最多的子函数会被标红色,最后可以将性能瓶颈定位到一个目标函数
经验猜想:根据经验假设接口缓慢的原因,情况及解决分多种
- 存在慢 SQL,可能是因为索引失效。解决方案为审查并优化SQL使索引生效,并且可在合适处使用缓存
- 程序逻辑存在问题。优化程序代码
- 代码中有同步执行的耗时操作。将同步串行的请求改为并行执行
- 接口设计问题。防止臃肿的接口设计。尽量将一个庞大的接口拆分成多个独立的小接口,使每个接口可以独立部署、维护、迭代
20.Tomcat结构
Tomcat是一个基于组件形式的的Web容器,由Server
(服务器)、Service
(服务)、Connector
(连接器)、Engine
(引擎)、Host
(主机)、Context
(应用服务)组成
Server
:表示整个Tomcat容器,包含了所有内容Service
:负责处理所有连接获取的客户端请求,包含多个连接器和1个引擎Connector
:用于在指定的接口上侦听客户的请求并将客户端请求交给引擎来进行处理,获得回应后返回响应给客户端Engine
:用于配置多个虚拟主机,每个虚拟主机都有一个域名,当引擎获得一个请求时,会把这个请求发送的相应的虚拟主机上,并且有个默认的虚拟主机,如果没有虚拟主机能够匹配这个请求,就由默认虚拟主机来进行处理请求Host
:虚拟主机,一个虚拟主机会与相对应的网络域名匹配,在虚拟主机中可以部署一个或多个WEB应用,每个web应用对应一个上下文,当一个host获取请求时,就把该请求匹配到某个context上处理Context
:一个WEB应用由一个或多个Servlet组成,Context在创建的时候将根据配置文件CATALINA_HOME/conf/web.xml
和WEBAPP_HOME/WEB-INF/web.xml
来载入servlet类,当Context获取请求时,将在自己的映射表中寻找相匹配的Servlet类,如果找到则执行该类的处理方法,获得请求响应并返回。
21.Tomcat怎么处理请求
Tomcat处理一个客户端请求http://localhost:8080/abc/index
的过程如下:
- 在服务器端的8080端口上负责监听的连接器接收到客户端发来的请求
- 连接器把该请求交给同一个Service下的引擎,并等待引擎的响应
- 引擎把URL进行解析,并把请求传给相对应的虚拟主机处理,如果没有相对应的虚拟主机,则用默认名叫localhost的虚拟主机来处理
- 虚拟主机再把URL解析为
/abc/index.html
,匹配context-path为/abc
的上下文去处理,如果匹配不到就把该请求交给路径名为””的上下文去处理 - context-path为
/abc
的上下文会匹配Servlet映射中为/index
的Servlet处理 - 构造
HttpServletRequest
对象和HttpServletResponse
对象,作为参数调用Servlet的doGet()
或doPost()
方法 - WEB应用的上下文会把处理后返回的
HttpServletResponse
对象返回给虚拟主机 - 虚拟主机再把
HttpServletResponse
对象返回给引擎 - 引擎把
HttpServletResponse
对象返回给连接器 - 最后连接器把
HttpServletResponse
对象返回给客户端结束请求处理
22.分布式ID生成要求
使用分布式全局唯一ID的原因:在复杂分布式系统中,往往需要对大量的数据和消息进行标识;如在一个数据日益增长的系统中,对数据分库分表时一定需要有一个分布式的唯一ID来标识一条数据。所以生成全局唯一ID的系统是非常有必要的
硬性要求
- 全局唯一 :不能出现重复的ID,要实现唯一标识
- 趋势递增 :在Mysql 的InnoDB引擎中用的是聚集索引,而多数RDBMS用的是B+tree数据结构来存储数据,在主键的选择上面应该尽量使用有序的主键保证数据写入
- 单调递增 :保证下一个ID一定大于上一个ID,例如事物版本号,增量消息
- 信息安全 :如果ID是连续的,恶意用户的扒取数据就非常容易来,直接按照顺序下载指定的URL,如果是订单号就更危险来,竞争对手可以知道一天的单量,所以在一些应用场景下需要ID不规则
- 含时间戳 :这样就能够在开发中快速了解这个分布式ID的生成时间
可用性要求
- 高可用 :对于创建分布式ID的请求,服务器就保证99.99%的情况下可以创建出唯一分布式ID
- 低延迟 :对于获取分布式ID的请求,服务器就做到快速响应
- 高QPS :服务器要能够接收并处理上万级的分布式ID请求
23.雪花算法
平时常用的分布式ID生成算法有UUID 、 数据库自增主键 、基于Redis 生成全局ID策略,但都存在弊端
- UUID不能生成顺序且递增的数据,并长度较长,不推荐采用
- 数据库自增在集群多的情况下,扩容极其困难
- Redis需要依靠Redis INCR 和 INCRBY 实现
Twitter开发的分布式自增ID算法snowflake(雪花算法)的特点如下
- 能够按照时间有序生成分布式ID
- 分布式系统不会产生重复ID(由datacenter和 workerld实现区分)且效率较高
- 雪花算法生成分布式ID的结果是一个64bit大小的整数,为一个Long 型(转换成字符后长度只有19位)
实现:雪花算法计算得到的结果为64bit大小的整数,可以划分成几个部分
- 1bit :符号位 。
- 41bit :时间戳。用于记录毫秒级的时间戳,41位可以表示2^41-1个数字,转换成单位年相当于(2^41-1)/ (1000x60x60x24x365)=69年
- 10bit:工作机器ID。用于记录工作机器ID,可以部署在2^10=1024 个节点,这10位包括了5位datacenterId 和5位的 workeId
- 12bit:序列号。序列号,用来记录同毫秒内产生的不同ID,12位可以表示的最大正整数是2^12-1 = 4095。即可以用这4095个数字来表示同一机器同一时间(毫秒)产生的4095个ID序号
雪花算法的实现实际上就是结合时间戳将上面的各个部分通过移位运算拼接得到。
优点
- 毫秒数在高位,自增序列在低位,这使得整个ID都是趋势递增的
- 不依赖数据库等第三方系统,以服务方式部署,稳定性更高,生成ID性能也非常高
- 可以根据自身业务特性分配bit位,非常灵活
缺点
- 依赖时钟机器,如果时钟机器回拨,会导致生成重复的分布式ID
- 在单机上时递增,但由于涉及到分布式环境,每个机器上的时钟几乎不可能完全同步,有时候会出现不是全局递增情况。但这个缺点可忽略,一般分布式ID只要求趋势(大体上)递增,并不会严格要求每个ID都递增
24.RPC服务与HTTP服务的区别
RPC主要用于系统内部的服务调用,传输效率高且性能消耗低,服务治理方便,而HTTP服务一般暴露给客户端,简单直接,开发方便。二者之间的区别在于
- 传输协议:RPC服务既可以基于TCP协议,也可以基于HTTP协议;HTTP基于HTTP协议
- 传输效率:RPC服务使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2.0协议,也可以很好的减小报文体积,提高传输效率;HTTP服务的请求只是在服务端内部使用会包含很多无用内容
- 主要用途:RPC服务多用于服务器内部的交互;HTTP服务面向客户端浏览器,提供了缓存,幂等或者cookies等有用的功能
- 性能消耗:RPC服务可以实现高效的二进制传输,性能消耗极低;HTTP服务大部分是基于JSON实现的,性能消耗没有RPC服务低
- 负载均衡:RPC服务基本都自带了负载均衡策略;HTTP服务则需要配置Nginx等中间件配置来实现负载均衡
- 服务治理:RPC服务能做到自动通知,不影响上游;HTTP服务则需要事先通知,如修改NGINX配置。
25.微服务的定义和优缺点
微服务:又叫微服务架构。微服务架构解决了传统单体式架构中过耦合、难扩展、一处出现问题会影响整个系统等问题,将一个复杂的应用拆分成多个独立自治的服务,服务与服务间通过松耦合的形式交互。每个微服务都是一个独立的实体,需要满足单一职责原则,它可以独立部署、升级,服务与服务之间通过REST等形式的标准接口进行通信,并且一个微服务实例可以被替换成另一种实现,而对其它的微服务不产生影响。
优点
- 逻辑清晰:每个微服务只负责其对应的业务,在逻辑上肯定要比一个复杂的系统更容易让人理解,并且逻辑清晰相应地能提高系统的可维护性
- 简化部署:每个微服务都可以实现独立部署。这样如果需要频繁更新系统时不用修改全部,而是通过很低的集成成本就可以快速发布新功能。
- 扩展性好:系统业务增长时通常采用横向或纵向的扩展方式,而在分布式系统中往往会采用横向方式进行扩展。因为不同的功能会面对不同的负荷变化,因此采用微服务的系统相对单块系统具备更好的可扩展性。
- 灵活组合:在微服务架构中,可以通过组合已有的微服务以达到功能重用的目的。
- 技术异构:由于微服务间实行松耦合,不同的微服务可以选择不同的技术栈进行开发。同时在应用新技术时,可以仅针对一个微服务进行快速改造,而不会影响系统中的其它微服务,有利于系统的演进。
- 高可靠:微服务间独立部署,一个微服务的异常不会导致其它微服务同时异常,并且还可以通过服务降级、服务熔断等技术提升系统的可靠性。
缺点
- 复杂度高:微服务间通过REST、RPC等形式交互,相对于单机架构,还需要额外考虑故障、过载、消息丢失等等异常情况,代码逻辑更加复杂。对于微服务间的事务性操作,因为不同的微服务采用了不同的数据库,将无法利用数据库本身的事务机制保证一致性,需要引入二阶段提交等技术。如果在微服务间存在少部分共用功能但又无法提取成微服务时,各个微服务对于这部分功能通常需要重复开发,或至少要做代码复制,以避免微服务间的耦合,增加了开发成本。
- 运维复杂:在采用微服务架构时,系统由多个独立运行的微服务构成,如果要一个设计良好的监控系统对各个微服务的运行状态进行监控,就需要运维人员对系统有细致的了解。
- 影响性能:相对于单机架构,微服务间要通过REST、RPC等形式进行交互,通信的时延会受到较大的影响。
软件设计
1.SOLID设计原则
- SRP(The Single Responsibility Principle):单一责任原则。修改一个类的原因应该只有一个,一个类只负责一件事,当这个类需要做过多事情的时候,就需要分解这个类来降低耦合
- OCP(The Open Closed Principle):开放封闭原则。类应该对扩展开放,对修改关闭。在添加新功能时不需要修改代码。
- LSP(The Liskov Substitution Principle):里氏替换原则。子类对象必须能够替换掉所有父类对象。
- ISP(The Interface Segregation Principle):接口分离原则。拆分庞大臃肿的接口为更小的和更具体的接口,而不应该强迫客户依赖于其不想用的方法,因此使用多个专门的接口比使用单一的总接口要好。
- DIP(The Dependency Inversion Principle):依赖倒置原则。高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。依赖于抽象意味着:
- 任何变量都不应该持有一个指向具体类的指针或者引用;
- 任何类都不应该从具体类派生;
- 任何方法都不应该覆写它的任何基类中的已经实现的方法。
2.单例模式
饿汉模式:在类加载的时候就对单实例进行创建,实例在整个程序周期都存在。这种实现方式适合单实例对象占用内存比较小,且在初始化时就会被用到的情况
- 其优点是只在类加载的时候创建一次单实例,不会存在多个线程创建多个实例的情况,从而避免了多线程同步的问题。
- 相对的,缺点就是即使这个单实例没有用到也会被创建,这样内存就被浪费了。
1 | public class Singleton{ |
懒汉模式:在需要时才创建单实例,如果单例已经创建,再次调用获取接口将不会重新创建新的对象,而是直接返回之前创建的对象。
- 其优势在于如果单实例使用次数少且创建消耗资源多时,让单实例按需创建能够节省资源
- 缺点在于懒汉模式没有考虑线程安全问题,在多个线程并发调用其
getInstance()
方法时,可能会导致创建出多个实例,因此需要加锁解决线程同步问题,但加锁也会带来一定的开销
1 | public class Singleton{ |
3.耦合类型有哪些
耦合性:耦合度,是对模块间关联程度的度量。耦合的强弱取决与模块间接口的复杂性、调用模块的方式以及通过界面传送数据的多少。模块间的耦合度是指模块之间的依赖关系,包括控制关系、调用关系、数据传递关系。模块间联系越多,其耦合性越强,同时表明其独立性越差。软件设计中通常用耦合度和内聚度作为衡量模块独立程度的标准。划分模块的一个准则就是高内聚低耦合。耦合的类型有如下几种(耦合性由强到弱):
- 内容耦合:如果发生下列情形,两个模块之间就发生了内容耦合。
- 一个模块直接访问另一个模块的内部数据
- 一个模块不通过正常入口转到另一模块内部
- 两个模块有一部分程序代码重叠(只可能出现在汇编语言中)
- 一个模块有多个入口
- 公共耦合:若一组模块都访问同一个公共数据环境,则它们之间的耦合就称为公共耦合。公共的数据环境可以是全局数据结构、共享的通信区、内存的公共覆盖区等。
- 外部耦合:一组模块都访问同一全局简单变量而不是同一全局数据结构,而且不是通过参数表传递该全局变量的信息,则称之为外部耦合。
- 控制耦合:如果一个模块通过传送开关、标志、名字等控制信息,明显地控制选择另一模块的功能,就是控制耦合。
- 印记耦合:如果一组模块通过参数表传递记录信息,就是标记耦合。它是某一数据结构的子结构,而不是简单变量。
- 数据耦合:如果一个模块访问另一个模块时,彼此之间是通过数据参数(不是控制参数、公共数据结构或外部变量)来交换输入、输出信息的,则称这种耦合为数据耦合。
- 非直接耦合:如果两个模块之间没有直接关系,它们之间的联系完全是通过主模块的控制和调用来实现的,这就是非直接耦合。这种耦合的模块独立性最强。
4.工厂模式
首先定义一个产品接口Shape
1 | interface Shape{ |
产品的实现分多种,有Circle、Rectagle等
1 | class Circle implements Shape{ |
1 | class Rectangle implements Shape{ |
定义一个工厂类,能够根据传入参数的不同得到不同的产品Shape
1 | class ShapeFactory { |
5.LRU缓存
LRU,Least Recently Used,最近最少使用。简单实现就是采用JDK中的LinkedHashap
,在查询缓存时移除原key值缓存数据再把其追加到尾部;在添加缓存时如果key值已存在,同样移除后再追加其到尾部表示最新使用,否则在添加后判断是否需要进行内存淘汰,即添加新缓存数据后将最近最少使用的头部缓存移除
1 | class LRUCache { |
6.递归和迭代的区别
- 实现方式:递归通过重复调用函数自身实现循环,迭代通过函数内的某段代码实现循环
- 结束条件:递归循环通过遇到满足终止条件的情况逐层返回来结束,迭代通过用计数器结束循环
- 空间利用率:迭代是逐渐逼近,用新值覆盖旧值,直到满足条件后结束,不保存中间值,空间利用率高。递归是将一个问题分解为若干相对小一点的问题,遇到递归出口再原路返回,因此必须保存相关的中间值,这些中间值压入栈保存,问题规模较大时会占用大量内存。
7.站内信设计
站内信:不同于邮件、短信需要通过专门的服务器发送、保存。站内信是网站系统内部的消息,很多时候都会使用到,比如系统推送公告,用户私信,订阅更新等功能。根据站内信的
发送范围
- 一对一:如私信。用户之间互相发送私信,或者是系统对某一特定用户推送的内容
- 一对多:如群发。一个用户对多个用户发送消息,或者是系统对某特定的用户群体推送内容
- 一对全体:如系统公告。系统对全体用户发送消息,每个用户都会收到公告
消息来源
- 用户事件触发:当某个用户对某个对象执行了评论、@、点赞、留言等动作,都需要对对象拥有者进行通知。这是最常见的需要通知的场景
- 满足系统规则后自动触发:比如被系统封号、等级提升、获得勋章时,理论上都应该对用户进行通知
- 管理员触发:管理员主动向全网或者某个用户发送通知,比如发送公告等。
内容模型
- 主语:用户XX、系统、管理员。该变量是为了告知接受者是谁触发了通知
- 关联的实体对象:问答、文章、专栏、评论、回复等
- 接收人:当前登录人
- 事件:点赞、评论、回复等,目的是为了具化站内信内容
具体设计
- 生成:很多时候站内信都是在某个用户进行某个动作的时候生成,比如评论。即在评论时除了保存评论相关信息,后台还需要生成站内信,并且保存评论和生成站内信两个操作应该采用异步方式,防止后者阻塞了前者的返回,影响用户体验。
- 保存:将站内信保存到数据库中,数据表的设计通常会涉及前面提到的内容模型:主语、关联对象、接收者、事件等
- 获取:用户在登陆网站或者打开 app 的时候触发查询,从数据库的站内信表中读取数据即可。
8.秒杀系统设计方案
秒杀系统会遇到的问题
- 短时间内的超高访问量对后台服务的冲击,在秒杀期间来自外部请求产生的QPS可能会是平时的近百倍
- 数据库的读写压力陡增,出现大量的并发写,会造成数据库的行锁处于无法释放的状态,大量的线程排队进而造成服务请求超时失败
- 网络带宽资源会因为被秒杀时建立的大量并发连接占据
- 此外还有超卖、恶意请求、链接暴露等问题
提前准备
- 让所有页面的静态资源放入CDN服务器,CSS, JS和图片放入CDN后,利用遍布全国的CDN节点,降低带宽和静态服务器的压力,避免网络带宽成为业务瓶颈。
- 准备独立服务器,秒杀系统需要单独部署,包括使用独立域名来避免秒杀业务对正常业务的系统的冲击和影响
- 建立性能测试环境,上线前根据本次秒杀的业务目标和流量预估,制定性能测试计划,只有在通过性能测试后才能真正上线。
前端设计
- 将秒杀产品的介绍、详情、参数等信息全部静态化,从而不需要通过后台API查询更新,减轻后端的压力。
- 用户触发”下单”事件后将秒杀按钮置灰,禁止重复提交请求
- 隐藏触发”下单”功能的URL防止优先提交,直到秒杀活动开始时通过更新JS文件显示URL
后端设计
- 检查用户身份,将无效的用户请求阻止在业务层外,过滤掉恶意请求。
- 在秒杀活动开始前,要将所有产品的属性和库存等数据信息预加载到缓存中,在秒杀过程中不主动更新数据库,而是采用延迟异步的方式进行更新。
- 对缓存服务器主从复制,写请求访问主节点,读请求访问从节点
- 写请求先写入缓存,再通过消息队列处理有限数量(秒杀商品数量)的下单请求,来延迟异步更新数据库
- 当秒杀时缓存的写操作暴增,可能会造成线程阻塞导致出现大量写超时,可以添加一个秒杀开关,当出现大量异常时,关闭开关停止一切秒杀活动,以免造成更大的损失。
系统监控
为了应对秒杀过程中的各种突发情况,我们还需要建立有效的监控手段来保障秒杀的过程。
- 监控Redis调用性能,主要看读和写的性能两个指标。
- 监控各个关键接口的运行情况,特别是下单接口的状态,看是否有大量请求超时或者异常的情况出现,关注失败的订单数,设置预警阈值。如果超过阈值,报警并采取紧急处理措施如关闭秒杀,进行服务降级。
- 监控数据库性能,密切关注订单写库的执行状态和读库的同步情况
海量数据
1.海量日志数据找出出现次数最多的那个IP
这类海量数据题都会一般会具体到1w/10w/100w这样的单位,但解决思路(分而治之+Hash)是一样的:
- 由于IP地址最多有2^32=4G种取值情况,所以不能完全加载到内存中处理,因此可以考虑采用分而治之的思想,按照IP地址的Hash(IP)%1024值,把海量IP日志分别存储到1024个小文件中。这样,每个小文件最多包含4M个IP地址
- 对于每一个小文件,可以构建一个IP为key,出现次数为value的Hash map,同时记录当前出现次数最多的那个IP地址
- 通过哈希表的记录可以得到1024个小文件中每个文件里出现次数最多的IP,再依据常规的排序算法得到总体上出现次数最多的IP
2.布隆过滤器
概念:布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
出现问题:当往列表中插入新数据时,往往将不会根据插入项的值来确定该插入项的索引值而是按照已有元素的大小放到末尾,这样就会导致在列表中搜索相应值时,必须遍历已有的集合,若集合中存在大量的数据,就会影响数据查找的效率。
解决方案:针对上述问题可以考虑使用哈希表,利用哈希表可以通过对值进行哈希处理(通过哈希函数hash()求值)来获得该值对应的键或索引值,然后把该值存放到列表中对应的索引位置。这意味着索引值是由插入项的值所确定的,当你需要判断列表中是否存在该值时,只需要对值进行哈希处理并在相应的索引位置进行搜索即可,这时的搜索速度是非常快的。
存在问题:布隆过滤器采用了上面的解决方案,但检查的结果只能是可能在集合中和绝不在集合中。可能表示结果会有一定的误判率,这是因为布隆过滤器本质上是由长度为 m 的位向量或位列表(仅包含 0 或 1 位值的列表)组成,最初所有的值均设置为0,之后提供N个不同的哈希函数,每添加一个数据就把该数据对应的N个哈希码(位)置为1。这样在查找的时候直接用N个哈希函数判断其对应的位是否为1即可,但是,由于会存在哈希冲突,所以有些数据在查找时可能集合中没有它但也能符合哈希结果为1,因此就出现了误判
结论:通过布隆过滤器搜索数据时,若该数据经过 N个哈希函数运算后的任何一个索引位为0,那么该值肯定不在集合中。但如果所有哈希索引值均为 1,则只能说该搜索的值可能存在集合中
应用场景如下:
- 网页爬虫去重:避免爬取相同的 URL 地址
- 反垃圾邮件:从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱
- 防止重复推荐:Medium 使用布隆过滤器避免推荐给用户已经读过的文章
- 帮助解决缓存穿透:利用布隆过滤器我们可以预先把数据查询的主键,比如用户 ID 或文章 ID 缓存到过滤器中。当根据 ID 进行数据查询的时候,我们先判断该 ID 是否存在,若存在的话,则进行下一步处理。若不存在的话,直接返回,这样就不会触发后续的数据库查询。需要注意的是缓存穿透不能完全解决,我们只能将其控制在一个可以容忍的范围内。
- 大数据查找:Google BigTable,Apache HBbase 和 Apache Cassandra 使用布隆过滤器减少对不存在的行和列的查找。在实际使用中可以通过导入google的guava包来使用BloomFilter过滤数据
3.给定两个文件存放海量URL,求两文件相同的数据部分
方案一:首先分别对两个文件数据采用分而治之(哈希取余) ,首先遍历第一个文件,对每个URL的哈希值取余,然后根据得到的值将URL分别存储到不同小文件中,然后再遍历第二个文件,采取和相同的方式将URL分别存储到不同小文件中。这样处理后所有可能相同的URL都在对应的小文件中,不对应的小文件不可能有相同的URL。此时有两堆小文件,把一堆小文件中的一个小文件放进哈希表,然后再遍历对应的另一个小文件,出现重复的则为相同的URL,以此类推
方案二:如果允许有一定的错误率,可以使用布隆过滤器,首先将一个文件中的URL存取到布隆过滤器中,然后再用布隆过滤器依次读取另一个文件的URL,如果经过过滤则该过滤的URL可能是相同的数据部分(存在一定错误率)
4.海量数据的排序问题
首先对于排序算法,分为内部排序和外部排序
内部排序:指待排序记录存放在计算机随机存储器(内存)中进行的排序过程。通常在程序代码中使用的冒泡排序、选择排序,插入排序,快速排序,堆排序,归并排序,希尔排序等十大排序都属于内部排序方法
外部排序:指待排序记录的数量较大,以至于内存一次不能容纳全部记录,在排序过程中需要对磁盘(外存)进行访问的排序过程。外部排序的基本思路是将待排序的记录存储在外部存储器上,在排序过程中需进行多次的内、外存之间的交换。可以分如下步骤
- 首先将大文件记录分成若干个子文件存储到磁盘上
- 依次将两个子文件读入内存中,利用内排序方法进行排序再次存储得到一个新的子文件
- 按上述步骤将不断对子文件进行逐个归并,最终会变成一个有序文件。
操作系统
1.PCB包含有什么信息
进程控制块(PCB Process Control Block)是用来描述进程的当前状态,本身特性的数据结构,是进程中组成的最关键部分,其中含有描述进程信息和控制信息,是进程的集中特性反映,是操作系统对进程具体进行识别和控制的依据。PCB一般包括:
- 程序ID(PID、进程句柄):它是唯一的,一个进程都必须对应一个PID。PID一般是整型数字
- 进程状态:运行、就绪、阻塞,表示进程现的运行情况
- 特征信息:一般分系统进程、用户进程、或者内核进程等
- 优先级:表示获得CPU控制权的优先级大小
- 通信信息:进程之间的通信关系的反映,由于操作系统会提供通信信道
- 现场保护区:保护阻塞的进程用
- 资源需求:分配控制信息
- 进程实体信息:指明程序路径和名称,进程数据在物理内存还是在交换分区(分页)中
- 其他信息:工作单位,工作区,文件信息等
2.协程与线程的关系
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
协程与线程的关系
- 一个线程可以拥有多个协程,一个进程也可以单独拥有多个协程,这样python中则能使用多核CPU
- 线程进程都是同步机制,而协程则是异步
- 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态
3.死锁的必要条件和处理方法
必要条件
- 互斥条件:一个资源每次只能被一个线程使用
- 占有与等待条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺
- 环路等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
处理方式
鸵鸟策略
因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能
死锁检测与死锁恢复
死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生
死锁恢复
- 系统重启。代价较大,会将参与死锁的那些进程及未参与死锁的进程完成的计算工作浪费。
- 撤消进程,剥夺资源。终止参与死锁的进程,收回它们占有的资源,从而解除死锁。这时又分两种情况:一次性撤消参与死锁的全部进程,剥夺全部资源;或者逐步撤消参与死锁的进程,逐步收回死锁进程占有的资源。
- 进程回退。让参与死锁的进程回退到没有发生死锁前某一点处,并由此点处继续执行,以求再次执行时不再发生死锁,但实操起来系统开销极大。
死锁预防
死锁是由四个必要条件导致的,所以只要破坏这四个必要条件中的一个条件,死锁情况就应该不会发生
- 打破互斥条件。需要允许进程同时访问某些资源,这种方法受制于实际场景,不易实现
- 打破不可抢占条件。需要允许进程强行从占有者那里夺取某些资源,也不易实现
- 打破占有与等待条件。进程在运行前申请得到所有的资源,否则该进程不能进入准备执行状态,缺点是可能导致资源利用率和进程并发性降低;
- 打破环路等待条件。避免出现资源申请环路,即对资源事先分类编号,按号分配。这种方式可以有效提高资源的利用率和系统吞吐量,但是增加了系统开销,增大了进程对资源的占用时间。
死锁避免
银行家算法
4.内存泄露、内存溢出
内存溢出:程序运行过程中申请内存大于系统能提供的内存,导致无法申请到足够内存,于是就发生了内存溢出。
内存泄漏:程序运行过程中分配内存给临时变量,用完之后却没有被GC回收,始终占用着内存,既不能被使用也不能分配给其他程序,于是就发生了内存泄漏
5.源文件到可执行文件的过程
hello.c -> hello.i -> hello.s -> hello.o -> hello
- 预处理阶段:处理以 # 开头的预处理命令
- 编译阶段:编译得到汇编文件
- 汇编阶段:将汇编文件汇编得到可重定位目标文件
- 链接阶段:将可重定位目标文件和 printf.o 等单独预编译好的目标文件通过链接(链接分为动态链接和静态链接)进行合并,得到最终的可执行目标文件
6.中断分类
中断:是处理器处理外部突发事件的一个重要技术。它能使处理器在运行过程中对外部事件发出的中断请求及时地进行处理,处理完成后又立即返回断点,继续进行处理器原来的工作。引起中断的原因或者说发出中断请求的来源叫做中断源。根据中断源的不同,可以把中断分为硬件中断和软件中断两大类,而硬件中断又可以分为外部中断和内部中断两类。
- 外中断:一般是指由计算机外设发出的中断请求(键盘中断、打印机中断、定时器中断等)。外部中断是可以屏蔽的中断,也就是说,利用中断控制器可以屏蔽这些外部设备的中断请求。外中断是可屏蔽中断。
- 内中断:一般是指因硬件出错(突然掉电、奇偶校验错等)或运算出错(除数为零、运算溢出、单步中断等)所引起的中断。内部中断是不可屏蔽的中断。
- 软件中断(陷入):系统调用触发的中断
7.单线程IO多路复用
传统的网络 I/O 模式也称BIO(Blocking IO):
在编写服务端网络程序时,创建套接字并绑定端口,然后用一个while
循环,在循环里调用 accept()
,程序会阻塞,一旦有连接到来accept()
就返回,然后针对该连接做相应的读写处理。
这种模式的特点就是简单直白,方式固定容易实现。但在同一时间,它只能处理一个客户端请求,因为它直接是在主线程中处理请求的,只有在上一个请求处理完毕,才能接着处理下一个请求,一旦某个请求处理较慢,那后面的请求只能等待。虽然也可以通过多线程模式来处理多个连接,但每个都将创建一个而线程,如果网络请求很多的话很快内存就将耗尽
为了解决传统网络 I/O 的问题,可以选择用单线程I/O多路复用的方式。IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出CPU。多路是指网络连接,复用指的是同一个线程。这是一种非阻塞模式,通过操作系统提供的select()
、poll()
、epoll()
、kqueue()
系统调用来实现。这些系统调用主要功能:监听一个或多个文件描述符上的各类事件,一旦文件描述符上有事件产生就返回。
因此I/O 多路复用的关键,是让内核监听文件描述符fd(Socket)的事件,而且可以同时监听多个 fd,和用一个线程处理一个Socket连接有根本的区别,它只需要一个线程或进程,就管理了多个连接。因此可以用一句话来概括 I/O 多路复用:在一个线程或一个进程中,监听了多个 fd。这里的复用,指的是多个 fd,或者说多个连接,复用了一个线程或者进程。
I/O多路复用的三种方式
select()
:第一个实现I/O多路复用的调用,支持跨平台。但监听的 fd 数量存在最大限制,在 Linux 上这个最大值是 1024。其次一旦监听的 fd 上有事件产生,select()
仅仅会返回,但并不会告诉我们是哪些 fd 产生了事件,这时需要自己遍历所有的 fdset,依次检查每个 fd 上的事件标志位。显然,遍历的这个过程时间复杂度是 O(n)。poll()
:去掉了 1024 这个最大监听数的限制,用户可以自定义监听 fd 数。简化了select()
调用方式,poll()
中fd 结构包含了要监听的event和发生的 event,但是poll()
依然需要遍历所有 fd 来检查事件,遍历的时间复杂度依然是 O(n)。epoll()
:没有最大监听数量上限,且只返回有事件发生的 fd,所以不需要遍历所有监听的 fd 来找到哪些 fd 产生了事件。因此,它的时间复杂度为 O(k),其中 k 为产生事件的 fd 数- 使用
epoll ()
时需要调用下列三个系统调用:
1
2
3int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);可以对应创建
epoll()
的句柄fd、对指定fd进行op操作、等待fd上的IO事件简单来看就是当调用
epoll_create()
时,内核就创建了一棵红黑树和一个就绪链表,其中,红黑树用于存储后面epoll_ctl()
传过来的 fd,以支持高效的查找、插入和删除。就绪链表用于存储准备就绪的事件,当epoll_wait()
调用时,仅仅观察这个就绪链表里有没有数据即可。有数据就返回,没有数据就 sleep,等到 timeout 时间到后即使链表没数据也返回。epoll()
有两种工作模式:LT模式和 ET模式,也叫水平触发和边沿触发,默认的是 LT 模式LT 模式:当
epoll_wait()
检测到 fd 事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait()
时,会再次响应应用程序并通知此事件。ET 模式:当
epoll_wait()
检测到 fd 事件发生,只有当 fd 事件变化时,即从unreadable变为 readable 或从 unwritable 变为 writable 时,它才返回事件 fd,因此应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此 fd 事件。ET 模式也称为高速模式,在很大程度上减少了epoll()
事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接字模式,以避免由于一个 fd 的阻塞读/阻塞写操作把处理多个 fd 的任务饿死总结:如果 fd 上有事件发生,LT 模式下会一直通知你,ET 模式只会通知一次。
- 使用
三个系统函数的应用场景
select()
应用场景:select()
的 timeout 参数精度为微秒,而poll()
和epoll()
为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。且select()
可移植性更好,几乎被所有主流平台所支持。poll()
应用场景:poll()
没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用poll()
而不是select()
。epoll()
应用场景:只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。 需要同时监控小于 1000 个描述符,就没有必要使用epoll()
。 需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用epoll()
。因为epoll()
中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过epoll_ctl()
进行系统调用,频繁系统调用降低效率。并且epoll()
的描述符存储在内核,不容易调试。
参考博客
很多博客(实在太多了,就不一一列举了…)