常见集合
集合相关类和接口都在java.util中,主要分为3种:List(列表)、Map(映射)、Set(集)。
其中Collection是集合List、Set的父接口,它的两个子接口:List存储的元素有序,可重复;Set存储的元素不无序,不可重复。Map是另外的接口,是键值对映射结构的集合。
ArrayList和LinkedList有什么区别?
- 数据结构不同
- ArrayList基于数组实现,更利于查找
- LinkedList基于双向链表实现,更利于增删
- ArrayList基于数组实现,get(int index)可以直接通过数组下标获取,时间复杂度是O(1);LinkedList基于链表实现,get(int index)需要遍历链表,时间复杂度是O(n);get(E element)这种查找,两种集合都需要遍历,时间复杂度都是O(n)。
- ArrayList增删如果是数组末尾的位置,直接插入或者删除就可以了,但是如果插入中间的位置,就需要把插入位置后的元素都向前或者向后移动,甚至还有可能触发扩容;双向链表的插入和删除只需要改变前驱节点、后继节点和插入节点的指向就行了,不需要移动元素。
- 二者增删的时间复杂度都是O(n)
- ArrayList基于数组,所以它可以根据下标查找,实现了RandmoAccess 接口 ,支持随机访问
- LinkedList基于链表,没有实现RandmoAccess 接口,不支持随机访问。
- ArrayList基于数组,是一块连续的内存空间,预先定义好数组大小,所以可能会有空的内存空间,存在一定空间浪费 。
- LinkedList基于链表,内存空间不连续,每个节点需要存储前驱和后继,空间占用上都有一些额外的消耗
ArrayList的扩容机制
ArrayList是基于数组的集合,在插入元素的时候,如果当前容量+1超过数组长度,就会进行扩容。
ArrayList的扩容是创建一个1.5倍的新数组,然后把原数组的值拷贝过去。
ArrayList怎么序列化的知道吗?为什么用transient修饰数组?
它不直接序列化元素数组,因为数组可能长度100,但实际只用了50,剩下的50不用其实不用序列化,这样可以提高序列化和反序列化的效率,还可以节省内存空间。
使用transient修饰存储元素的elementData的数组,transient关键字的作用是让被修饰的成员属性不被序列化。
ArrayList通过两个方法writeObject、readObject自定义序列化和反序列化策略,实际直接使用两个流ObjectOutputStream
和ObjectInputStream
来进行序列化和反序列化。
快速失败(fail-fast)和安全失败(fail-safe)
快速失败
- 在用迭代器遍历一个集合对象时,如果线程A遍历过程中,线程B对集合对象的内容进行了修改(增加、删除、修改),则会抛出ConcurrentModificationException。
- 原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个
modCount
变量。集合在被遍历期间如果内容发生变化,就会改变modCount
的值。每当迭代器遍历下一个元素之前,都会检测modCount变量是否等于expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。 - 如果集合发生变化时modCount值刚好又等于expectedmodCount值,则异常不会抛出。因此这个异常只建议用于检测并发修改的bug。
- java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如ArrayList
安全失败
- 采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
- 原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
- 缺点:迭代器并不能访问到修改后的内容,所以遍历到的数据可能不少最新的。
- 场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如CopyOnWriteArrayList类。
实现ArrayList线程安全的方法
- 使用 Vector 代替 ArrayList。(不推荐,Vector是一个历史遗留类)
- 使用 CopyOnWriteArrayList 代替 ArrayList。
- 在使用 ArrayList 时,应用程序通过同步机制去控制 ArrayList 的读写。
CopyOnWriteArrayList了解多少
CopyOnWriteArrayList就是线程安全版本的ArrayList。
CopyOnWriteArrayList采用了一种读写分离的并发策略。CopyOnWriteArrayList容器允许并发读,读操作是无锁的,性能较高。
至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。
HashMap的数据结构
JDK1.7的数据结构是数组+链表
JDK1.8的数据结构是数组+链表+红黑树。
桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。
- 数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置
- 如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素
- 如果链表长度>8&数组大小>=64,链表转为红黑树
- 如果红黑树节点个数<6 ,转为链表
HashMap的put流程
首先进行哈希值的扰动,获取一个新的哈希值
判断当前数组是否为空或者长度为0,如果是则进行扩容操作。
根据哈希值计算下标,如果对应下标正好没有存放数据,则直接插入
如果有元素,判断key是否相同,相同则覆盖
key不同的话,判断当前节点是否是链表,key存在,则覆盖,不存在,则尾部插入元素。
判断链表如果长度大于8,达到树化阈值,链表转红黑树。
如果该节点是树节点,插入元素,
最后判断容量是否超过阈值需要resize扩容,结束。
你对红黑树了解多少?为什么不用二叉树/平衡树呢?
红黑树本质上是一种二叉查找树,为了保持平衡,它又在二叉查找树的基础上增加了一些规则:
- 每个节点要么是红色,要么是黑色;
- 根节点永远是黑色的;
- 所有的叶子节点都是是黑色的(注意这里说叶子节点其实是图中的 NULL 节点);
- 每个红色节点的两个子节点一定都是黑色;
- 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;
之所以不用二叉树,红黑树是一种平衡的二叉树,插入、删除、查找的最坏时间复杂度都为 O(logn),避免了二叉树最坏情况下的O(n)时间复杂度。
之所以不用平衡二叉树: 平衡二叉树是比红黑树更严格的平衡树,为了保持保持平衡,需要旋转的次数更多,也就是说平衡二叉树保持平衡的效率更低,所以平衡二叉树插入和删除的效率比红黑树要低。
红黑树怎么保持平衡的
左旋,右旋,染色
HashMap怎么查找元素
- 使用扰动函数,获取新的哈希值
- 计算数组下标,获取节点
- 当前节点和key匹配,直接返回
- 否则,当前节点是否为树节点,查找红黑树
- 否则,遍历链表查找
HashMap的哈希/扰动函数是怎么设计
HashMap的哈希函数是先拿到 key 的hashcode,是一个32位的int类型的数值,然后让hashcode的高16位和低16位进行异或操作。 降低哈希碰撞的概率
为什么哈希/扰动函数能降hash碰撞?
因为 key.hashCode() 函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。int 值范围为 -2147483648~2147483647,加起来大概 40 亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。假如 HashMap 数组的初始大小才 16,就需要用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。从而降低hash碰撞
为什么HashMap的容量是2的倍数呢
第一个原因是为了方便哈希取余 ,将元素放在table数组上面,是用hash值%数组大小定位位置,而HashMap是用hash值&(数组大小-1),却能和前面达到一样的效果,这就得益于HashMap的大小是2的倍数,2的倍数意味着该数的二进制位只有一位为1,而该数-1就可以得到二进制位上1变成0,后面的0变成1,再通过&运算,就可以得到和%一样的效果,并且位运算比%的效率高得多 。HashMap的容量是2的n次幂时 ,元素的hash值进行位运算时,能够充分的散列,使得添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞。
第二个方面是在扩容时,利用扩容后的大小也是2的倍数,将已经产生hash碰撞的元素完美的转移到新的table中去 。元素超过负载因子*HashMap大小 就会扩容。
哈希函数的构造方法
除留取余法、直接定址法 、数字分析法 、平方取中法 、折叠法
解决哈希冲突有哪些方法
链地址法、开放定址法 、再哈希法 、建立公共溢出区
为什么HashMap链表转红黑树的阈值为8
链表里的节点符合泊松分布,出现节点个数的概率是递减的,节点个数为8的情况,发生概率特别低。
红黑树转回链表的阈值为什么是6,而不是8?是因为如果这个阈值也设置成8,假如发生碰撞,节点增减刚好在8附近,会发生链表和红黑树的不断转换,导致资源浪费。
为什么扩容因子是0.75
为了减少哈希冲突发生的概率,当当前HashMap的元素个数达到一个临界值的时候,就会触发扩容,把所有元素rehash之后再放在扩容后的容器中,这是一个相当耗时的操作。
临界值threshold
就是由扩容因子和当前容器的容量大小来确定的 。
假如我们设的比较大,元素比较多,空位比较少的时候才扩容,那么发生哈希冲突的概率就增加了,查找的时间成本就增加了。
我们设的比较小的话,元素比较少,空位比较多的时候就扩容了,发生哈希碰撞的概率就降低了,查找时间成本降低,但是就需要更多的空间去存储元素,空间成本就增加了。
扩容机制
HashMap是基于数组+链表和红黑树实现的,但用于存放key值的桶数组的长度是固定的,由初始化参数确定。
因为HashMap的初始容量是2的次幂,扩容之后的长度是原来的二倍,新的容量也是2的次幂,所以,元素,要么在原位置,要么在原位置再加上原来的容量值。
在扩容时,只需要看原来的hash值新增的那一位是0还是1就行了,是0的话索引没变,是1的化变成原索引+oldCap
,
jdk1.8对HashMap主要做了哪些优化
-
数据结构:数组 + 链表改成了数组 + 链表或红黑树
原因
:发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n)
降为O(logn)
-
链表插入方式:链表的插入方式从头插法改成了尾插法
简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。
原因
:因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。 -
扩容rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。
原因:
提高扩容的效率,更快地扩容。 -
扩容时机:在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容;
-
散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次,提升效率。
设计实现一个HashMap
- 创建散列函数:hashCode()+除留余数法
- 解决哈希冲突:链地址法
- 编写扩容逻辑:节点重新hash获取位置
HashSet的底层实现
HashSet 底层就是基于 HashMap 实现的 ,HashSet的add方法,直接调用HashMap的put方法,将添加的元素作为key,new一个Object作为value,它会根据返回值是否为空来判断是否插入元素成功。
TreeMap 怎么实现有序
TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,要么自定义一个实现了 Comparator 接口的比较器,传给 TreeMap 用于 key 的比较。
LinkedHashMap 怎么实现有序
LinkedHashMap维护了一个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点,可以实现按插入的顺序或访问顺序排序。
HashMap 在多线程下会有什么问题?
- 多线程下扩容死循环。JDK1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8 使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程的 put 可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在 JDK 1.7 和 JDK 1.8 中都存在。
- put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出 threshold 而导致 rehash,线程 2 此时执行 get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在。
如何解决HashMap线程不安全
- HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;
- ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。
ConcurrentHashmap的实现
整个流程和HashMap非常类似,只不过是先定位到具体的Segment,然后通过ReentrantLock去操作而已,后面的流程,就和HashMap基本上是一样的。
- put流程计算hash,定位到segment,segment如果是空就先初始化
- 使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功
- 遍历HashEntry,就是和HashMap一样,数组中key和hash一样就直接替换,不存在就再插入链表,链表同样操作类似hashmap
get也很简单,key通过hash定位到segment,再遍历链表定位到具体的元素上,需要注意的是value是volatile的,所以get是不需要加锁的。
CAS+synchronized
dk1.8实现线程安全不是在数据结构上下功夫,它的数据结构和HashMap是一样的,数组+链表+红黑树。它实现线程安全的关键点在于put流程。
- 首先计算hash,遍历node数组,如果node是空的话,就通过CAS+自旋的方式初始化
- 如果当前数组位置是空则直接通过CAS自旋写入数据
- 如果hash==MOVED,说明需要扩容,执行扩容
- 如果都不满足,就使用synchronized写入数据,写入数据同样判断链表、红黑树,链表写入和HashMap的方式一样,key hash一样就覆盖,反之就尾插法,链表长度超过8就转换成红黑树
CAS是什么
比较并交换(compare and swap, CAS),是原子操作的一种。在多线程没有锁的状态下,可以保证多个线程对同一个值的更新。该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。
- CAS结合volatile可以实现无锁并发,适用于线程数少,多核CPU场景下。
- CAS是基于乐观锁实现(本身并无锁,区别于synchronized)
- CAS体现的是无锁并发、无阻塞并发
什么是JVM
Java虚拟机,用来实现Java跨平台。
Java程序运行的时候,编译器将Java文件编译成平台无关的Java字节码文件(.class),JVM对字节码文件进行解释,翻译成对应平台的机器指令并运行。
JVM的内存区域
JVM内存分为线程私有区和线程共享区,其中方法区
和堆
是线程共享区,虚拟机栈
、本地方法栈
和程序计数器
是线程隔离的数据区。
- 程序计数器:它可以看作是当前线程所执行的字节码的行号指示器。
- 虚拟机栈:方法执行时,JVM会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态连接等。
- 本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
- java堆:此内存区域的唯一目的就是存放对象实例,Java里几乎 所有的对象实例都在这里分配内存。 Java堆是垃圾收集器管理的内存区域
- 方法区:方法区是比较特别的一块区域,和堆类似,它也是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。jdk1.7之前使用永久代作为方法区的实现。
JDK1.6、1.7、1.8内存区域的变化
- JDK1.6使用永久代实现方法区:
- JDK1.7时将字符串常量池、静态变量,存放在堆上
- JDK1.8时直接内存中划出一块区域作为元空间替换永久代。
为什么使用元空间替代永久代
- 客观上使用永久代来实现方法区的决定的设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小 )
- 主观上当Oracle收购BEA获得了JRockit的所有权后 ,把JRockit中的优秀功能,移植到HotSpot 虚拟机时。
对象创建的过程
- new指令
- 执行相应的类加载过程
- 类加载检查通过后,接下来虚拟机将为新生对象分配内存。
- 内存分配完成之后,虚拟机将分配到的内存空间都初始化为零值。
- 接下来设置对象头,对象头里包含了对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
类的生命周期
一个类从被加载到虚拟机内存中开始,到从内存中卸载,整个生命周期需要经过七个阶段:加载、验证、准备、解析、初始化 、使用和卸载,其中验证、准备、解析三个部分统称为连接。
什么是指针碰撞?什么是空闲列表
内存分配有两种方式,指针碰撞和空闲列表。
- 指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
- 空闲列表:如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
- 两种方式的选择由Java堆是否规整决定,Java堆是否规整是由选择的垃圾收集器是否具有压缩整理能力决定的
JVM 里 new 对象时,堆会发生抢占吗?JVM是怎么设计来保证线程安全
会,假设JVM虚拟机上,每一次new 对象时,指针就会向右移动一个对象size的距离,一个线程正在给A对象分配内存,指针还没有来的及修改,另一个为B对象分配内存的线程,又引用了这个指针来分配内存,这就发生了抢占
- 采用CAS分配重试的方式来保证更新操作的原子性
- 每个线程在Java堆中预先分配一小块内存。
对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头主要由两部分组成:
- 第一部分存储对象自身的运行时数据。
- 第二部分是类型指针。
- 如果对象是一个Java数组,那还应该有一块用于记录数组长度的数据
实例数据用来存储对象真正的有效信息,也就是我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承的,还是自己定义的。
**对齐填充:**占位符的作用。
对象怎么访问定位
Java程序会通过栈上的reference数据来操作堆上的具体对象 。
主要的访问方式有句柄访问和直接指针
- 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址
- 如果使用直接指针访问 的话,reference中存储的直接就是对象地址
内存溢出和内存泄漏
内存泄露就是申请的内存空间没有被正确释放,导致内存被白白占用。
内存溢出OOM就是申请的内存超过了可用内存,内存不够了。
两者关系:内存泄露可能会导致内存溢出。
- 用一个有味道的比喻,内存溢出就是排队去蹲坑,发现没坑位了,内存泄漏,就是有人占着茅坑不拉屎,占着茅坑不拉屎的多了可能会导致坑位不够用。
手写内存溢出的例子
Java堆溢出 ,死循环向数组中添加实例
虚拟机栈OOM:不断地去创建线程
内存泄漏可能由哪些原因导致
- 静态集合类引起内存泄漏:静态集合的生命周期和 JVM 一致,所以静态集合引用的对象不能被释放 。
- 变量不合理的作用域:一个变量的定义作用域大于其使用范围,很可能存在内存泄漏;例如变量定义在方法外部,而在方法内部将变量等于null了,则不会马上释放
- IO、Socket等连接:连接不再使用时,未调用 close 方法关闭连接,JVM无法回收
- hash值发生变化,使用HashMap、HashSet等容器的时候,由于对象修改之后的Hash值和存储进容器时的Hash值不同,所以无法找到存入的对象,自然也无法单独删除了 ,所以String类型被设置成了不可变类型
如何判断对象仍然存活
有两种方式,引用计数算法和可达性分析算法。
Java中可作为GC Roots的对象有哪几种?
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
对象有哪几种引用
Java中的引用有四种,分为强引用、软引用、弱引用和虚引用4种,这4种引用强度依次逐渐减弱。
finalize()方法有什么作用
用一个不太贴切的比喻,垃圾回收就是古代的秋后问斩,finalize()就是刀下留人,在人犯被处决之前,还要做最后一次审计,青天大老爷看看有没有什么冤情,需不需要刀下留人。
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。如果对象在在finalize()中重新与引用链上的任何一个对象建立关联即可,那在第二次标记时就不会被回收了。
Java堆的内存分区了解吗
按照垃圾收集,将Java堆划分为新生代和老年代两个区域,新生代存放存活时间短的对象,每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
垃圾收集算法
- 标记-清除算法
- 标记-复制算法:新生代垃圾收集主要采用这种算法
- 标记-整理算法:标记-整理算法主要用于老年代
新生代的区域划分
而新生代又可以分为三个区域,eden、from、to,比例是8:1:1。
在GC开始的时候,对象只会存在于Eden区和名为From区,To是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。
Minor GC/Young GC、Major GC/Old GC、Mixed GC、Full GC?
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
新生代收集器什么时候触发
新创建的对象优先在新生代Eden区进行分配,如果Eden区没有足够的空间时,就会触发Young GC来清理新生代
什么时候会触发Full GC?
Young GC之前检查老年代 、Young GC之后老年代空间不足、老年代空间不足
空间分配担保失败、方法区内存空间不足、System.gc()等命令触发
对象什么时候会进入老年代?
长期存活的对象、大对象、空间分配担保
有哪些垃圾收集器
Serial收集器(单线程) 、ParNew (多线程)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,老年代的收集器,采用标记-清除算法。
Garbage First(简称G1)收集器是垃圾收集器的一个颠覆性的产物,它开创了局部收集的设计思路和基于Region的内存布局形式。
什么是Stop The World ?什么是 OopMap
进行垃圾回收的过程中,必须暂停所有的用户线程,简称为STW。
数据结构(映射表)称为OopMap
:旦类加载动作完成的时候,HotSpot就会把对象的以下数据记录到OopMap
CMS收集器的垃圾收集过程
- 初始标记(CMS initial mark):单线程运行,需要Stop The World,标记GC Roots能直达的对象。
- 并发标记((CMS concurrent mark):无停顿,和用户线程同时运行,从GC Roots直达对象开始遍历整个对象图。
- 重新标记(CMS remark):多线程运行,需要Stop The World,标记并发标记阶段产生对象。
- 并发清除(CMS concurrent sweep):无停顿,和用户线程同时运行,清理掉标记阶段标记的死亡的对象。
G1垃圾收集器
它开创了局部收集的设计思路和基于Region的内存布局形式。
G1把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。
- 初始标记(initial mark),标记了从GC Root开始直接关联可达的对象。STW(Stop the World)执行。
- 并发标记(concurrent marking),和用户线程并发执行,从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象、
- 最终标记(Remark),STW,标记再并发标记过程中产生的垃圾。
- 筛选回收(Live Data Counting And Evacuation),制定回收计划,选择多个Region 构成回收集,把回收集中Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。需要STW。
有了CMS,为什么还要引入G1
CMS最主要的优点在名字上已经体现出来——并发收集、低停顿。
CMS同样有三个明显的缺点。
- 内存碎片比较多
- CMS的并发能力比较依赖于CPU资源,性能下降。
- 并发清除阶段,用户线程依然在运行,会产生所谓的浮动垃圾,如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。
G1主要解决了内存碎片过多的问题。
你们线上用的什么垃圾收集器?为什么要用它?
采用Parallel New
+CMS
的组合,我们比较关注服务的响应速度,所以采用了CMS来降低停顿时间。
或者一步到位:
我们线上采用了设计比较优秀的G1垃圾收集器,因为它不仅满足我们低停顿的要求,而且解决了CMS的浮动垃圾问题、内存碎片问题。
对象一定分配在堆中吗?有没有了解逃逸分析技术?
不一定的 。
通俗点讲,当一个对象被new出来之后,它可能被外部所调用,如果是作为参数传递到外部了,就称之为方法逃逸。
逃逸分析的好处
-
栈上分配:垃圾收集的压力就降低很多
-
同步消除
命令行性能监控和故障处理工具
-
操作系统工具
-
top:显示系统整体资源使用情况
-
vmstat:监控内存和CPU
-
iostat:监控IO使用
-
netstat:监控网络使用
-
-
JDK性能监控工具
-
jps:虚拟机进程查看
-
jstat:虚拟机运行时信息查看
-
jinfo:虚拟机配置查看
-
jstack:Java堆栈跟踪
-
可视化的性能监控和故障处理工具
JConsole 、Java Mission Control
MAT:Java 堆内存分析工具。
JVM的参数
- -Xms:初始堆大小
- -Xmx:最大堆大小
- -XX:NewSize=n:设置年轻代大小
- -XX:MaxPermSize=n:设置老年代大小
JVM调优
订单系统做压力测试的时候,偶发性的引发OOM异常 ,于是将堆内存调整为8G,-Xms:8g
但并没有解决问题,于是分析代码和JProfiler 分析堆内存的dump文件 发现是String对象 对象导致的,
通过代码分析,导出订单时会创建大量的String对象,因为导出订单数据本来就非常慢,使用的人员可能发现点击后很久后页面都没反应,然后就一直点,结果就大量的请求进入到后台
通过两个方法解决:
- 在前端的导出订单按钮上加上了置灰状态,等后端响应之后按钮才可以进行点击
- 后端代码加分布式锁,做防重处理
线上服务CPU占用过高怎么排查
top命令:列出各个进程的使用情况,找到CPU占用高的进程ID
top -Hp 进程ID :列出对应线程的占用资源情况
jstack PID 打印出进程的所有线程堆栈信息
根据线程的堆栈信息定位具体的业务方法
我们遇到的是多线程请求服务器生成密钥的时候,CPU过高,最后定位到是发送TCP报文时会对报文计算一次MAC,而mac算法使用不恰当,很耗时,通过替换mac算法,解决。
频繁 minor gc 怎么办
通过增大新生代空间-Xmn
来降低Minor GC的频率
有没有处理过内存泄漏问题?是如何定位的?
定位:
- 应用程序长时间连续运行时性能严重下降
- CPU 使用率飙升,甚至到 100%
- 频繁 Full GC,各种报警,例如接口超时报警等
- 应用程序抛出
OutOfMemoryError
错误 - 应用程序偶尔会耗尽连接对象
分析:
- 使用
jps
查看运行的 Java 进程 ID - 查看进程使用 CPU 和 MEM 的情况
- 每隔 5 秒输出 GC 信息,输出 10 次,查看 Full GC 次数
- 使用 jmap 生成 dump 文件。
- 在 dump 文析结果中查找存在大量的对象,再查对其的引用。
解决:
top命令:列出各个进程的使用情况,找到CPU占用高的进程ID
top -Hp 进程ID :列出对应线程的占用资源情况
jstack PID 打印出进程的所有线程堆栈信息
根据线程的堆栈信息定位具体的业务方法
我们遇到的是多线程请求服务器生成密钥的时候,CPU过高,最后定位到是发送TCP报文时会对报文计算一次MAC,而mac算法使用不恰当,很耗时,通过替换mac算法,解决。
类加载的过程
- 通过一个类的全限定名来获取此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象。
Tomcat的类加载机制
Tomcat实际上也是破坏了双亲委派模型的 。
Tomact是web容器,可能需要部署多个应用程序。不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。如多个应用都要依赖hollis.jar,但是A应用需要依赖1.0.0版本,但是B应用需要依赖1.0.1版本。这两个版本中都有一个类是com.hollis.Test.class。如果采用默认的双亲委派类加载机制,那么无法加载多个相同的类。
Tomcat为每个web容器单独提供一个WebAppClassLoader加载器。每一个WebAppClassLoader负责加载本身的目录下的class文件。
类加载器有哪些
- 启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用。
- 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
- 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。
- 用户自定义类加载器 (user class loader),用户通过继承 java.lang.ClassLoader类的方式自行实现的类加载器。
双亲委派机制
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。
为什么要用双亲委派机制?
保证类的一致,保证应用程序的稳定有序。
例如类java.lang.Object,它存放在rt.jar之中,通过双亲委派机制,保证最终都是委派给处于模型最顶端的启动类加载器进行加载,保证Object的一致。反之,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的 ClassPath中,那系统中就会出现多个不同的Object类。
如何破坏双亲委派机制
重写ClassLoader类中的 loadClass()方法。
不想打破双亲委派模型,就重写ClassLoader类中的findClass()方法即可
你觉得应该怎么实现一个热部署功能
将java类卸载,并且替换更新版本的java类
自定义类加载器,并重写ClassLoader的findClass方法。
- 销毁原来的自定义ClassLoader
- 更新class类文件
- 创建新的ClassLoader去加载更新后的class类文件。
并行跟并发的区别
- 并行就是同一时刻,两个线程都在执行。
- 并发就是同一时刻,只有一个线程执行,但是一个时间段内,两个线程都执行了。
什么是进程和线程
- 进程是操作系统进行资源分配和调度的基本单位。
- 线程是进程的一个执行路径,是 CPU分配的基本单位 ,进程中的多个线程共享进程的资源。
线程有几种创建方式
继承Thread类,重写run()方法,调用start()方法启动线程
实现 Runnable 接口,重写run()方法
实现Callable接口,重写call()方法
线程有哪些常用的调度方法
等待、通知、让出优先权 、中断、休眠
线程有几种状态
初始状态、运行状态、阻塞状态、等待状态、超时等待、终止状态
什么是线程上下文切换
CPU 采用时间片轮转做到资源分配就是上下文切换。
守护线程了解吗
Java中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。
当最后一个非守护线程束时, JVM会正常退出,而不管当前是否存在守护线程 。
线程间有哪些通信方式
-
volatile和synchronized关键字
volatile:就是告知程序任何对该变量的访问均需要从共享内存中获取
-
等待/通知机制(wait()/notify())
-
使用Thread.join()
如果一个线程A执行了thread.join()语句,当前线程A等待thread线程终止之后才从thread.join()返回
ThreadLocal是什么
ThreadLocal,也就是线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,起到线程隔离的作用,线程安全。
ThreadLocal怎么实现的
- Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,每个线程都有一个属于自己的ThreadLocalMap。
- ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象
- 每个线程在往ThreadLocal里读写值的时候,都是在自己的ThreadLocalMap里读写
- ThreadLocal本身不存储值,它只是作为一个key来让线程往ThreadLocalMap里存取值。
ThreadLocalMap的结构了解吗
元素数组
和散列方法
ThreadLocalMap怎么解决Hash冲突
开放定址法
在工作中用到过ThreadLocal吗?
有用到过的,用来做用户信息上下文的存储。
在控制层拦截请求把用户信息存入ThreadLocal,这样我们在任何一个地方,都可以取出ThreadLocal中存的用户数据。
父子线程怎么共享数据
在主线程的InheritableThreadLocal实例设置值,在子线程中就可以拿到了。
java内存模型(JMM)
JMM属于语言级的内存模型 ,屏蔽各种硬件和操作系统的内存访问差异。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存
(Main Memory)中,每个线程都有一个私有的本地内存
(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。
说说你对原子性、可见性、有序性的理解?
- 原子性:原子性指的是一个操作是不可分割、不可中断的,要么全部执行并且执行的过程不会被任何因素打断,要么就全不执行。只能保证一条指令的原子性。
- 可见性:可见性指的是一个线程修改了某一个共享变量的值时,其它线程能够立即知道这个修改。
- 有序性:有序性指的是对于一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的,但是并发时有可能会发生指令重排。
什么是指令重排
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
as-if-serial又是什么?单线程的程序一定是顺序的吗
不一定是按照顺序。
as-if-serial语义的意思是:不管怎么重排序,单线程的执行结果不能被改变。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
volatile实现原理
一个变量被声明为volatile 时,线程在写入变量时会把值刷新回主内存,当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中值
重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。
synchronized的实现原理
synchronized修饰代码块时,JVM采用monitorenter
、monitorexit
两个指令来实现同步,monitorenter
指令指向同步代码块的开始位置
synchronized修饰同步方法时,JVM采用ACC_SYNCHRONIZED
标记符来实现同步
monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。
所谓的Monitor可以叫做内部锁,或者Monitor锁。
除了原子性,synchronized可见性,有序性,可重入性怎么实现?
- 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
- 线程加锁后,其它线程无法获取主内存中的共享变量。
- 线程解锁前,必须把共享变量的最新值刷新到主内存中。
- 之所以是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。
ReentrantLock怎么实现的
new ReentrantLock()
构造函数默认创建的是非公平锁 NonfairSync
FairSync、NonfairSync 代表公平锁和非公平锁,两者都是 ReentrantLock 静态内部类
说说synchronized和ReentrantLock的区别
- 锁的实现: synchronized是Java语言的关键字,基于JVM实现。而ReentrantLock是基于JDK的API层面实现的(一般是lock()和unlock()方法配合try/finally 语句块来完成。)
- 性能: 在JDK1.6锁优化以前,synchronized的性能比ReenTrantLock差很多。但是JDK6开始,增加了适应性自旋、锁消除等,两者性能就差不多了。
- ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized不用手动释放锁。
- ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
公平锁 FairSync
- 公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁
- 公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大
非公平锁 NonfairSync
- 非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁
- 非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁
Java有哪些保证原子性的方法?如何保证多线程下i++ 结果正确?
- 使用循环原子类,例如AtomicInteger,实现i++原子操作
- 使用juc包下的锁,如ReentrantLock ,对i++操作加锁lock.lock()来实现原子性
- 使用synchronized,对i++操作加锁
原子操作类了解多少?
- AtomicBoolean:原子更新布尔类型。
- AtomicInteger:原子更新整型。
- AtomicLong:原子更新长整型。
- AtomicIntegerArray:原子更新整型数组里的元素。
- AtomicLongArray:原子更新长整型数组里的元素。
- AtomicReferenceArray:原子更新引用类型数组里的元素。
线程死锁了解吗?该如何避免
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
- 互斥条件
- 请求并持有条件
- 环路等待条件:线程——资源环形链,
避免?
- 对于“请求并持有”这个条件,可以一次性请求所有的资源。
- 对于“不可剥夺”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
死锁问题怎么排查
- 使用jps查找运行的Java进程:jps -l
- 使用jstack查看线程堆栈信息:jstack -l 进程id
或者使用JConsole。出现线程死锁以后,点击JConsole线程面板的检测到死锁
按钮,将会看到线程的死锁信息。
CountDownLatch(倒计数器)了解吗?
协调子线程结束动作:等待所有子线程运行结束
协调子线程开始动作:统一各线程动作开始的时机
CyclicBarrier(同步屏障)了解吗
它和CountDownLatch类似,都可以协调多线程的结束动作,在它们结束后都可以执行特定动作,但是为什么要有CyclicBarrier,自然是它有和CountDownLatch不同的地方:
- CountDownLatch是一次性的,而CyclicBarrier则可以多次设置屏障,实现重复利用;
- CountDownLatch中的各个子线程不可以等待其他线程,只能完成自己的任务;而CyclicBarrier中的各个线程可以等待其他线程
Semaphore(信号量)了解吗
就是个信号灯。
Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。
Exchanger 了解吗
Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。
Exchanger可以用于遗传算法,遗传算法里需要选出两个人作为交配对象,这时候会交换两人的数据,并使用交叉规则得出2个交配结果 。
什么是线程池?
它帮我们管理线程,避免增加创建线程和销毁线程的资源损耗、提高响应速度,重复利用
有哪几种常见的线程池?
- newFixedThreadPool (固定数目线程的线程池)
- newCachedThreadPool (可缓存线程的线程池)
- newSingleThreadExecutor (单线程的线程池)
- newScheduledThreadPool (定时及周期执行的线程池)
线程池主要参数有哪些
corePoolSize :核心线程数
maximumPoolSize :允许的最大线程数
keepAliveTime :非核心线程闲置下来后的存活时间。
unit :线程池中非核心线程保持存活的时间的单位
workQueue :线程池等待队列
线程池的拒绝策略
- AbortPolicy :直接抛出异常,默认使用此策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列里最老的任务,也就是队列里靠前的任务
- DiscardPolicy :当前任务直接丢弃
想实现自己的拒绝策略,实现RejectedExecutionHandler接口即可。
线程池有哪几种工作队列
-
ArrayBlockingQueue:ArrayBlockingQueue(有界队列)是一个用数组实现的有界阻塞队列.
-
LinkedBlockingQueue:LinkedBlockingQueue(可设置容量队列)是基于链表结构的阻塞队列,按FIFO排序任务,newFixedThreadPool线程池使用了这个队列
-
DelayQueue:DelayQueue(延迟队列)是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排序,否则根据插入到队列的先后排序。newScheduledThreadPool线程池使用了这个队列。
-
SynchronousQueue:同步队列是一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene,
newCachedThreadPool线程池使用了这个队列。
工作中线程池的应用
之前我们有一个和第三方对接的需求,需要向第三方推送我们客户端的日志,引入了多线程来提升日志推送的效率,其中用到了线程池来管理线程。
- corePoolSize:线程核心参数选择了CPU数×2
- maximumPoolSize:最大线程数选择了和核心线程数相同
- keepAliveTime:非核心闲置线程存活时间直接置为0
- workQueue:线程池等待队列,使用 LinkedBlockingQueue阻塞队列
线程池的工作流程
- 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
- 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会根据拒绝策略来对应处理。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
线程池有几种状态
running
- 该状态的线程池会接收新任务,并处理阻塞队列中的任务;
- 调用线程池的shutdown()方法,可以切换到SHUTDOWN状态;
shutdown
- 该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;
- 队列为空,并且线程池中执行的任务也为空,进入TIDYING状态;
stop
- 该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;
- 线程池中执行的任务为空,进入TIDYING状态;
tidying
- 该状态表明所有的任务已经运行终止,记录的任务数量为0。
- terminated()执行完毕,进入TERMINATED状态
terminated
- 该状态表示线程池彻底终止
线程池提交execute和submit有什么区别?
execute 用于提交不需要返回值的任务 .
submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个 future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值
线程池怎么关闭
可以通过调用线程池的shutdown
或shutdownNow
方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,但是无法响应中断的任务可能永远无法终止。
线程池异常怎么处理知道吗?
在使用线程池处理任务的时候,任务代码可能抛出RuntimeException
- try-catch捕获异常
- submit执行,Future.get接收异常
- Thread.UncaughtExceptionHandler捕获系统的异常
线程池如何实现参数的动态修改
- 在我们微服务的架构下,可以利用配置中心修改线程池的参数。
- 手动扩展ThreadPoolExecutor,重写方法.
你能设计实现一个线程池吗
- 线程池中有N个工作线程
- 把任务提交给线程池运行
- 如果线程已满,把任务放入队列
- 最后当有空闲时,获取队列中任务来执行
单机线程池执行断电了应该怎么处理?
对阻塞队列持久化;通过日志恢复该次操作;服务器重启后阻塞队列中的数据再加载。
String 为什么要设计成不可变的?
- 如果字符串变量允许可变,会导致各种逻辑错误,如改变一个对象会影响到另一个独立对象。
- String 对象可以缓存 hashCode。字符串的不可变性保证了 hash 码的唯一性,因此可以缓 存 String 的hashCode,这样不用每次去重新计算哈希码。在进行字符串比较时,可以直接比较 hashCode,提高了比较性能;
- String 被许多 java 类用来当作参数,如 url 地址,文件 path 路径,反射机制所需的 String参数等,若 String 可变,将会引起各种安全隐患。
Java 中 String 的了解
- String 类是 final 型,固 String 类不能被继承,它的成员方法也都默认为 final 方法
- String 类是通过 char 数组来保存字符串的
- new String(“test”)产生的对象会保存在堆中
说一下泛型原理,并举例说明
泛型就是将类型变成参数传入,使得可以使用的类型多样化,从而实现解耦。Java 泛型是在
Java1.5 以后出现的,为保持对以前版本的兼容,使用了擦除的方法实现泛型。
说说你对 Java 注解的理解
注解是通过@interface 关键字来进行定义的,形式和接口差不多,只是前面多了一个@
元标签有@Retention @Documented 。
@Retention 说明注解的存活时间
@Documented 注解中的元素包含到 javadoc 中去
@Target 限 定 注 解 的 应 用 场 景
1)提供信息给编译器:编译器可利用注解来探测错误和警告信息
2)编译阶段:软件工具可以利用注解信息来生成代码、html 文档或做其它相应处理;
3)运行阶段:程序运行时可利用注解提取代码
Java 中实现多态的机制是什么?
多态是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编 译时不确定,在运行期间才确定,一个引用变量到底会指向哪个类的实例。这样就可以不用 修改源程序,就可以让引用变量绑定到各种不同的类实现上
常见编码方式
ASCII 码:总共 128 个,用一个字节的低 7 位表示。
ISO-8859-1,用来扩展 ASCII 编码,256 个字符,涵盖了大多数西欧语言字符。
GB2312:双字节编码。
GBK 为了扩展 GB2312,加入了更多的汉字。
UTF-16: UTF-16 定义了 Unicode 字符在计算机中存取方法,用两个字节来表示。
UTF-8:UTF-8 采用一种变长技术,每个编码区域有不同的字码长度,不同类型的字符可以由1~6个字节组成。
如何反转一个链表
-
第一种方法:原地反转。
-
第二种方法:利用头插法进行反转链表。
-
第三种方法:利用迭代法进行反转链表。
-
第四种方法:利用递归法进行反转链表。
如何合并两个有序链表
设定两个指针,分别指向链表1的头和链表2的头;创建新的链表头p;
如果p1的值<p2的值,则p1的值为新链表的头节点值,即p=p1->value;反之,则p2的值为新链表的头节点值,即p=p2->value;利用递归依次比较
如何删除链表的倒数第N个节点
先统计节点总数count,然后算出倒数第n个数为正数count - n + 1,最后正向迭代删除指定位置的节点。
如何检测链表是否有环
穷举遍历 、哈希表缓存
如何用栈实现队列
因为队列先进先出,栈先进后出,所以用两个栈实现队列。栈s1用来入队,栈s2用来出队。
入队:对入队的栈s1直接进行元素入栈。
出队:当出队的栈s2不为空时,s2直接出栈;若s2为空,将s1的元素都导入出队的栈s2里,然后s2进行出栈。
如何在一个无序数组中查找某个元素
先排序,再按照二分法查找
穷举法
什么是二叉树
二叉树特点是每个节点最多只能有两棵子树,且有左右之分 。
如何判断一棵树是否是二叉查找树
中序遍历整棵树,如果该中序遍历的序列是升序的,则是搜索二叉树,否则不是
用递归套路解决 :X左树的最大值 < X节点的值; X节点的值 < X 右树的最小值;
如何遍历二叉树
前序遍历、中序遍历、后续遍历
如何获取二叉树的深度
1、一颗树只有一个节点,它的深度是1;
2、二叉树的根节点只有左子树而没有右子树,那么可以判断,二叉树的深度应该是其左子树的深度加1;
3、二叉树的根节点只有右子树而没有左子树,那么可以判断,那么二叉树的深度应该是其右树的深度加1;
4、二叉树的根节点既有右子树又有左子树,那么可以判断,那么二叉树的深度应该是其左右子树的深度较大值加1。
如何在一棵树中查找某个节点。
1.先判断当前节点的no是否等于要查找的
2.如果是相等,则返回当前节点
3.如果不等,则判断当前节点的左子节点是否为空,如果不为空,则递归前序查找
4.如果左递归前序查找,找到节点,则返回,否继续判断,当前的节点的右子节点是否为空,如果不为空,则继续向右递归前序查找。
什么是哈希表
是根据关键码值而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
如何解决哈希冲突
开放寻址法、链地址法(拉链法)、再哈希法、建立公共溢出区等方法。
图形数据结构
图形数据结构主要谈论几何形体在计算机内部的表示以及期间进行运算的基本方法
如何遍历图
- 深度优先遍历
- 广度优先遍历
TCP/HTTP协议握手挥手原理
在正常情况下,TCP 要经过三次握手建立连接,四次挥手断开连接。
-
客户端TCP向服务器端TCP发送 SYN同步报文段,请求建立连接。
-
服务器端TCP 接收到同步报文段之后,就向客户端TCP 发送 SYN应答报文,表示客户端的 SYN 报文已被服务端成功接收了。
-
当客户端收到确认报文之后,继续会向服务器端TCP 发送确认报文段ACK,确认连接。
-
三次握手的目的:防止重复连接、同步初始化
四次挥手的过程
1. 第一次挥手:客户端发送一个FIN=M,用来关闭客户端到服务器端的数据传送,客户端进入FIN_WAIT_1状态。意思是说"我客户端没有数据要发给你了",但是如果你服务器端还有数据没有发送完成,则不必急着关闭连接,可以继续发送数据
2. 第二次挥手:服务器端收到FIN后,先发送ack=M+1,告诉客户端,你的请求我收到了,但是我还没准备好,请继续你等我的消息。这个时候客户端就进入FIN_WAIT_2 状态,继续等待服务器端的FIN报文。
3. 第三次挥手:当服务器端确定数据已发送完成,则向客户端发送FIN=N报文,告诉客户端,好了,我这边数据发完了,准备好关闭连接了。服务器端进入LAST_ACK状态。
4. 第四次挥手:客户端收到FIN=N报文后,就知道可以关闭连接了,但是他还是不相信网络,怕服务器端不知道要关闭,所以发送ack=N+1后进入TIME_WAIT状态,如果Server端没有收到ACK则可以重传。服务器端收到ACK后,就知道可以断开连接了。客户端等待了2MSL后依然没有收到回复,则证明服务器端已正常关闭,那好,我客户端也可以关闭连接了。最终完成了四次握手。
HTTPS协议原理
举例:A把消息传给B
Client发起一个HTTPS的请求。
Server把代表自己的公钥证书返回给客户端。
Client验证公钥证书。
Client生成加密所使用的对称密钥,然后用证书的公钥加密这个对称密钥,发给Server。
Server使用自己的私钥(private key)解密这个消息,得到对称密钥。至此,Client和Server双方都持有了相同的对称密钥。
Server使用对称密钥加密“明文内容A”,发送给Client。
Client使用对称密钥解密响应的密文,得到“明文内容A”。
Client再次发起HTTPS的请求,使用对称密钥加密请求的“明文内容B”,然后Server使用对称密钥解密密文,得到“明文内容B”。
NIO比BIO相比有什么优势?
- BIO是面向流的传输;NIO是面向块传输,按块处理数据要比按字节处理数据快得多
- BIO是采用同步阻塞机制,一个线程资源只能处理一个链接;NIO是采用多路复用IO,可以处理多个链接
- NIO使用了零拷贝,提高了传输性能
NIO有哪些组件?
-
Channel:对数据的源头和数据目标点流经途径的抽象
-
SocketChannel:用于发起TCP连接,读写网络中的数据,通常用于客户端的实现
-
Selector:通道注册器,多个Channel均可以注册到Selector,Selector负责监听每个Channel的几个事件:链接就绪、写就绪、读就绪。
-
Buffer:内存块,底层是一个数组。Buffer提供了一组方法轻松使用内存块,Channel读取或写入数据都必须经过Buffer
NIO存在哪些问题?
- 半包读写:TCP传输的数据是以流的形式,所以TCP也没办法判断哪一段流属于哪一个消息,所以存在粘包和半包问题
- 断连重连:服务端出现异常时,客户端可能存在连接断开需重新连接的情况
操作系统有哪些IO模型?
A: 有五种模型:阻塞IO、非阻塞IO、多路复用IO、信号渠道IO、异步IO
- 阻塞IO:阻塞等待数据准备,数据准备好后,等待数据从内核复制到用户空间
- 非阻塞IO:不等待数据准备,通过轮询方式判断数据准备好后,等待数据从内核复制到用户空间
- 多路复用IO:发生请求后,将socket注册到select,select轮询检查多个socket数据是否准备好,数据准备好后,等待数据从内核复制到用户空间(同步)
- 信号渠道IO:发生请求后,内核通过信号量标志数据是否准备好,数据准备好后,等待数据从内核复制到用户空间
- 异步IO:发生请求后,不等待数据准备(非阻塞),数据准备好后,内核将数据复制到用户空间,然后发送信号通知数据已经复制完成(异步)
反向代理和正向代理
反向代理代理的是服务器,正向代理代理的是客户端
为什么要用Nginx?
- 跨平台、配置简单、反向代理、高并发连接
- Nginx内置的健康检查功能:如果有一个服务器宕机,会做一个健康检查,再发送的请求就不会发送到宕机的服务器了
- 节省宽带:支持GZIP压缩,可以添加浏览器本地缓存
- 接收用户请求是异步的
为什么Nginx性能这么高?
因为他的事件处理机制 s异步非阻塞事件处理机制:运用了epoll模型,提供了一个队列,排队解决。
什么是同步、异步、阻塞、非阻塞?
一次IO的过程主要是:应用向操作系统发起IO指令(read、write),操作系统内核准备数据(把IO外部设备的数据,加载到内核缓冲区),准备完毕后将数据从内核空间复制到用户空间。
内核准备数据过程中,用户进程是否被阻塞
阻塞:用户进程直到内核数据准备完成,才返回
非阻塞:用户进程不等待内核数据准备完成,立即返回
同步:用户进程等待数据从内核空间复制到用户空间
异步:用户进程不等待数据复制,内核复制完成后,发送信号通知进程
Nginx怎么处理请求的?
nginx接收一个请求后,首先由listen和server_name指令匹配server模块,再匹配server模块里的location,location就是实际地址 。
使用反向代理服务器的优点是什么?
反向代理服务器可以隐藏源服务器的存在和特征。它充当互联网云和web服务器之间的中间层。这对于安全方面来说是很好的,特别是当您使用web托管服务时。
Nginx的优缺点?
优点:
(1)占内存小,可实现高并发连接,处理响应快
(2)可实现http服务器、虚拟主机、方向代理、负载均衡
(3)Nginx配置简单
(4)可以不暴露正式的服务器IP地址
缺点:
动态处理差:nginx处理静态文件好,耗费内存少,但是处理动态页面则很鸡肋,现在一般前端用nginx作为反向代理抗住压力。
Nginx应用场景?
- http服务器。Nginx是一个http服务可以独立提供http服务。可以做网页静态服务器。
- 虚拟主机。可以实现在一台服务器虚拟出多个网站,例如个人网站使用的虚拟机。
- 反向代理,负载均衡。当网站的访问量达到一定程度后,单台服务器不能满足用户的请求时,需要用多台服务器集群可以使用nginx做反向代理。并且多台服务器可以平均分担负载,不会应为某台服务器负载高宕机而某台服务器闲置的情况。
- nginx 中也可以配置安全管理、比如可以使用Nginx搭建API接口网关,对每个接口服务进行拦截。
- 动静分离
Nginx目录结构有哪些?
conf 、html、logs、sbin等
Nginx配置文件nginx.conf有哪些属性模块?
worker_processes 1; # worker进程的数量
events { # 事件区块开始
worker_connections 1024; # 每个worker进程支持的最大连接数
} # 事件区块结束
http { # HTTP区块开始
include mime.types; # Nginx支持的媒体类型库文件
default_type application/octet-stream; # 默认的媒体类型
sendfile on; # 开启高效传输模式
keepalive_timeout 65; # 连接超时
server { # 第一个Server区块开始,表示一个独立的虚拟主机站点
listen 80; # 提供服务的端口,默认80
server_name localhost; # 提供服务的域名主机名
location / { # 第一个location区块开始
root html; # 站点的根目录,相当于Nginx的安装目录
index index.html index.htm; # 默认的首页文件,多个用空格分开
} # 第一个location区块结果
error_page 500502503504 /50x.html; # 出现对应的http状态码时,使用50x.html回应客户
location = /50x.html { # location区块开始,访问50x.html
root html; # 指定对应的站点目录为html
}
}
Nginx虚拟主机怎么配置?
- 基于域名的虚拟主机,通过域名来区分虚拟主机——应用:外部网站
- 基于端口的虚拟主机,通过端口来区分虚拟主机——应用:公司内部网站,外部网站的管理后台
- 基于ip的虚拟主机。
location的作用是什么?
location指令的作用是根据用户请求的URI来执行不同的应用。
location的语法
- /:通用匹配,任何请求都会匹配到
- =:精确匹配
- ^~:以某个字符串匹配
- ~:区分大小写的正则
- ~*:不区分大小写的正则
限流怎么做的?
基于露桶流算法
- 正常限制访问频率(正常流量)
- 突发限制访问频率(突发流量)
- 限制并发连接数
为什么要做动静分离?
静分离是让动态网站里的动态网页根据一定规则把不变的资源和经常变的资源区分开来,动静资源做好了拆分以后,我们则根据静态资源的特点将其做缓存操作。
- 让静态的资源只走静态资源服务器,动态的走动态的服务器
- 对于静态资源比如图片,js,css等文件,我们则在反向代理服务器nginx中进行缓存。这样浏览器在请求一个静态资源时,代理服务器nginx就可以直接处理,无需将请求转发给后端服务器tomcat。
- 若用户请求的动态文件,比如servlet,jsp则转发给Tomcat服务器处理,从而实现动静分离。这也是反向代理服务器的一个重要的作用。
Nginx怎么做的动静分离?
只需要指定路径对应的目录。location/可以使用正则表达式匹配。并指定对应的硬盘中的目录。如下:(操作都是在Linux上)
server{
listen 80;
server_name ws.licaidie.top;
location /image/ {
root /usr/local/static/;
autoindex on;
}
}
Nginx负载均衡的算法怎么实现的?
轮询(默认) 、权重 weight 、ip_hash( IP绑定)
nginx是如何实现高并发的?
简单来讲,就是: 异步,非阻塞,使用了epoll和大量的底层代码优化
nginx采用一个master进程,多个woker进程的模式。
- master进程主要负责收集、分发请求。当一个请求过来时,master拉起一个worker进程负责处理这个请求。
- master进程也要负责监控woker的状态,保证高可靠性
- woker进程一般设置为跟cpu核心数一致。nginx的woker进程跟apache不一样。apche的进程在同一时间只能处理一个请求,所以它会开很多个进程,几百甚至几千个。而nginx的woker进程在同一时间可以处理的请求数只受内存限制,因此可以处理多个请求。
nginx的四大功能是什么?
正向代理、反向代理、动静分离、负载均衡
Nginx 常用命令有哪些?
启动 nginx 。
停止 nginx -s stop 或 nginx -s quit 。
重启 nginx -s reload 或 service nginx reload 。
重载指定配置文件 .nginx -c /usr/local/nginx/conf/nginx.conf 。
查看 nginx 版本 nginx -v 。
nginx报500、502、503、504 有什么区别?
500:内部服务错误,比如脚本错误,编程语言语法错误。
502: Bad Gateway错误,网关错误。比如服务器当前连接太多,响应太慢,页面素材太多、带宽慢。
503: 服务不可用,web服务器不能处理HTTP请求,ip超频访问导致限流,或者 临时超载或者是服务器进行停机维护。
504:网关超时,程序执行时间过长导致响应超时,例如程序需要执行20秒,而nginx最大响应等待时间为10秒,这样就会出现超时。
什么是C10K问题?
所谓c10k问题,指的是: 服务器如何支持10k个并发连接,也就是concurrent 10000 connection(这也是c10k这个名字的由来), C10K问题是指无法同时处理大量客户端(10,000)的网络套接字。
解决:IO多路复用
如何列出所有正在运行的机器
docker ps
docker容器的生命周期
created:初建状态
running:运行状态
stopped:停止状态
paused: 暂停状态
deleted: 删除状态
docker create命令有什么用
用来创建容器,类似于docker run,但是docker create不会启动容器,只是创建。
linux常用命令
-
用户和用户组操作命令。
- useradd 选项 用户名 。groupadd 选项 用户组名 。
- groupmod -n 新组名 老组名
- useradd -g 组名 用户名。
- passwd 选项 用户名 。
- userdel 用户名 :删除用户。groupdel 组名 。
- su:切换用户
- userdel -r 用户名 :用户和用户主目录,都删除 。
- groups 用户名称 :查看用户属于哪个组
-
权限命令
chmod、umask(该命令可以为用户账号中新文件的创建进行缺省设置)、chown(更改与文件关联的所有者或组 )、chgrp(变更文件或目录的所属群组 )
-
软件安装命令:RPM yum
-
进程命令:ps、pstree、top
-
内存监控命令:free
-
压缩打包命令:tar、gzip
-
文件查找用grep
-
字符串查找find
Netty 是什么?
Netty是 一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。
Netty是基于nio的,它封装了jdk的nio,让我们使用起来更加方法灵活。
JDK原生NIO程序的问题?
- JDK NIO的BUG,例如臭名昭著的select bug,它会导致Selector空轮询,最终导致CPU 100%。
- NIO的类库和API繁杂,使用麻烦
- 需要熟悉Java多线程编程
Netty 的优势有哪些?
- 使用简单:封装了 NIO 的很多细节,使用更简单。
- 功能强大:预置了多种编解码功能,支持多种主流协议。
- 定制能力强:可以通过 ChannelHandler 对通信框架进行灵活地扩展。
- 性能高:通过与其他业界主流的 NIO 框架对比,Netty 的综合性能最优。
- 稳定:Netty 修复了已经发现的所有 NIO 的 bug,让开发人员可以专注于业务本身。
- 社区活跃:Netty 是活跃的开源项目,版本迭代周期短,bug 修复速度快。
Netty 的应用场景有哪些?
- 阿里分布式服务框架 Dubbo,默认使用 Netty 作为基础通信组件,
- 还有 RocketMQ 也是使用 Netty 作为通讯的基础。
Netty 高性能表现在哪些方面?
- IO 线程模型:通过多线程Reactor反应器模式,在应用层实现异步非阻塞(异步事件驱动)架构,用最少的资源做更多的事。
- 内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。
- 内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。 (具体请参考尼恩稍后的手写内存池)
- 对象池设计:Java对象可以重用,主要指Minior GC非常频繁的对象,如ByteBuffer。并且,对象池使用无锁架构,性能非常高。 (具体请参考尼恩稍后的手写对象池)
- mpsc无锁编程:串形化处理读写, 避免使用锁带来的性能开销。
- 高性能序列化协议:支持 protobuf 等高性能序列化协议。
Netty 和 Tomcat 的区别?
作用不同:Tomcat 是 Servlet 容器,可以视为 Web 服务器,而 Netty 是异步事件驱动的网络应用程序框架和工具用于简化网络编程,例如TCP和UDP套接字服务器。
协议不同:Tomcat 是基于 http 协议的 Web 服务器,而 Netty 能通过编程自定义各种协议,因为 Netty 本身自己能编码/解码字节流,所有 Netty 可以实现,HTTP 服务器、FTP 服务器、UDP 服务器、RPC 服务器、WebSocket 服务器、Redis 的 Proxy 服务器、MySQL 的 Proxy 服务器等等。
NIO和BIO到底有什么区别?有什么关系?
NIO是以块的方式处理数据,BIO是以字节流或者字符流的形式去处理数据。
NIO是通过缓存区和通道的方式处理数据,BIO是通过InputStream和OutputStream流的方式处理数据。
NIO的通道是双向的,BIO流的方向只能是单向的。
NIO采用的多路复用的同步非阻塞IO模型,BIO采用的是普通的同步阻塞IO模型。
NIO的效率比BIO要高,NIO适用于网络IO,BIO适用于文件IO。
NIO是如何实现同步非阻塞的?
一个线程 Thread 使用一个选择器Selector监听多个通道 Channel 上的IO事件,从而让一个线程就可以处理多个IO事件。通过配置监听的通道Channel为非阻塞,那么当Channel上的IO事件还未到达时,线程会在select方法被挂起,让出CPU资源。直到监听到Channel有IO事件发生时,才会进行相应的响应和处理。
Selector只有在通道上有真正的IO事件发生时,才会进行相应的处理,这就不必为每个连接都创建一个线程,避免线程资源的浪费和多线程之间的上下文切换导致的开销。
Netty线程模型
Netty主要基于主从Reactors多线程模型(如下图)做了一定的修改,其中主从Reactor多线程模型有多个Reactor:MainReactor和SubReactor:
MainReactor负责客户端的连接请求,并将请求转交给SubReactor
SubReactor负责相应通道的IO读写请求
非IO请求(具体逻辑处理)的任务则会直接写入队列,等待worker threads进行处理
Netty解决Selector空轮询BUG的策略
若Selector的轮询结果为空,也没有wakeup或新消息处理,则发生空轮询,CPU使用率100%。
- 对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,若在某个周期内连续发生N次空轮询,则触发了epoll死循环bug。
- 重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除,重新注册到新的Selector上,并将原来的Selector关闭。重建selector,重新注册channel通道。