文章目录
- 接口、类与继承
- java中创建对象有哪几种方式?
- == 和equal区别是什么?
- hashCode()
- 为什么重写equals方法必须重写hashcode方法?
- String为什么设计成不可变的?
- String,StringBuffer,StringBuilder的区别是什么?
- 静态内部类和非静态内部类的区别
- 多态的理解
- 多态的转型
- 理解多态的案例
- 多态方法调用的优先级
- java中接口和类的区别
- 集合
- 线程安全的集合有哪些?线程不安全的呢?
- List
- ArrayList与LinkedList异同点
- ArrayList与Vector的区别
- Array和ArrayList的区别
- Map
- HashMap默认的负载因子是多少?为什么是0.75
- HashMap中key的存储索引是怎么计算的?
- HashMap的put方法流程
- HashMap的扩容方法?
- 一般用什么作为HashMap的key?
- HashMap为什么线程不安全?
- ConcurrentHashMap
- JDK1.7的concurrentHashMap
- JDK1.8的concurrentHashMap
- Collection框架实现比较要怎么做?
- 线程并发
- 多线程基础
- 线程和进程的区别
- 什么是线程死锁?死锁产生条件?
- 常见的对比
- runnable vs callable
- shutdown() vs shutdownNow()
- isTerminated() vs isShutdown()
- sleep() vs wait
- CAS(compare and swap)
- synchronize
- synchronize和volatile的区别是什么
- synchronized和Lock有什么区别?
- synchronized和reentrantLock区别是什么
- synchronize底层实现原理
- 多线程中 synchronized 锁升级的原理是什么?
- ThreadLocal
- 线程池
- 使用线程池的好处
- 线程池大小确定
- 线程池执行任务的流程
- 线程池常用的阻塞队列有哪些
- JVM
- 内存
- JVM内存结构是怎样的?
- 谈谈对OOM的认识,如何排查OOM问题
- 谈谈JVM中的常量池
- 如何判断一个对象是否存活?
- java的四种引用是什么?
- GC(Garbage Collection,垃圾收集)
- JVM的垃圾回收算法有哪些?
- 类加载
- 什么是类加载?类加载的过程?
- 什么是类加载器,常见的类加载器有哪些?
- 什么是双亲委派机制?
接口、类与继承
java中创建对象有哪几种方式?
- new
- 反射
- clone
- 序列化
== 和equal区别是什么?
- 如果==比较的是基本数据类型,那么比较值是否相等
- 如果==是比较两个对象,那么比较的是对象的引用,也就是判断两个对象是否指向了同一块内存区域。
equal方法用于两个对象之间,检测一个对象是否等于另一个对象。
Object类中equal()的源码等价于通过“==”比较两个对象
但一般而言,我们使用equal的目的是为了比较两个对象的内容是否相同,因此,一般会重写equals()方法,来比较它们的内容是否相等。
hashCode()
hashCode()的作用是获取哈希码,也称为散列码,它实际上返回一个int整数,这个哈希吗的作用是确定该对象在哈希表中的索引位置。hashCode()定义在JDK的Object.java,每个对象都包含有hashCode()函数。
为什么重写equals方法必须重写hashcode方法?
- 判断两个对象是否相等时,现根据hashcode进行判断,相同的情况下再根据equals()方法进行判断,如果只重写了equals方法,而不重写hashcode的方法,会造成hashcode的值不同,而equals()方法返回为true。
String为什么设计成不可变的?
- 便于实现字符串池(String pool):在java中由于会大量使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间浪费。。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。
- 使线程安全。不变的字符串对象,保证了线程安全。
- 避免网络安全。网络连接地址URL、文件路径path,都需要String参数,其不可变性,防止了黑客的篡改。
- 加快字符串处理速度。
String,StringBuffer,StringBuilder的区别是什么?
- 可变与不可变。String类中使用字符数组保存字符串,因为有“final”修饰符,所以
String对象是不可变的
,对于已经存在的String对象的修改都是重新创建一个新的对象,然后把新值保存进去。
StringBuffer与StringBuilder都继承自AbstractStringBuilder
类,在AbstractStringBuilder中也是使用字符数组保存字符串的,这两种对象都是可变的。
private final char value[]; // String
char[] value; // StringBuffer、StringBuilder
- 是否线程安全。
- String中的对象是不可变的,也就可以理解为常量,线程安全。
- StringBuilder是非线程安全的。
- StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以说线程安全的。
@Override
public synchronize StringBuffer append(String str) {toStringCache = null;super.append(str);return this;
}
如果只是在单线程使用字符串缓冲区,那么StringBuilder的效率会更高些。需要考虑线程安全的,用StringBuffer。
静态内部类和非静态内部类的区别
静态内部类只能方法外部类的静态成员和静态方法
非静态内部类:不管是静态方法还是非静态方法都可以在非静态内部类中访问。
静态内部类和非静态内部类的主要不同:
- 静态内部类不依赖外部类的实例化而被实例化,但非静态内部类需要在外部类实例化后才可以被实例化。
- 静态内部类不需要持有外部类的引用,但非静态内部类需要持有外部类的引用
- 静态内部类不能访问外部类的非静态成员和非静态方法,非静态内部类则没有此限制,可以访问外部类的静态和非静态成员和方法
多态的理解
方法重载overload
实现了编译时多态,同名函数根据不同的参数列表,表现出不同的形式。
子类方法对于父类方法的重写override
实现运行时多态,java运行 时系统根据该方法的实例的类型来决定选择调用哪个方法被称为运行时多态。
运行时多态存在的必要条件:
- 子类继承父类
- 子类重写父类方法
- 父类引用指向子类对象
多态的转型
多态的转型分为向上转型和向下转型两种。
-
向上转型:多态本身就是向上转型的过程
使用格式: 父类类型 变量名 = new 子类类型(); -
向下转型:一个已经向上转型的子类对象,可以使用强制类型转换的格式,将父类引用类型,转为子类引用类型
使用格式:子类类型 变量名 = (子类类型) 父类类型的变量;
理解多态的案例
案例一:
class People{public void eat(){System.out.println("吃饭");}
}
class Stu extends People{@Overridepublic void eat(){System.out.println("吃水煮肉片");}public void study(){System.out.println("好好学习");}
}
class Teachers extends People{@Overridepublic void eat(){System.out.println("吃樱桃");}public void teach(){System.out.println("认真授课");}
}public class demo04 {
public static void main(String[] args) {People p=new Stu();p.eat(); // 吃水煮肉片//调用特有的方法Stu s=(Stu)p;s.study();s.eat(); // 吃水煮肉片}
}
案例二
这个案例的第三个输出可能与我们所想不一致。
当超类对象引用变量引用子类对象时,被引用对象的类型而不是引用变量的类型决定了调用谁的成员方法,但是这个被调用的方法必须是在超类中定义过的,也就是被子类覆盖的方法但是如果强制把超类转为子类,就可以调用子类中新添加而超类中没有的方法
上边的第三条:a2.show(b)
,a2是按照多态方式得到的一个B类,入参b是B类,B继承自A,而A中并没有入参为B类的show()
,所有B中的两个show()
函数,入参为A类的是对父类的重写,入参为B的show函数是自己新有的方法。
那么根据上边的观点,a2只能调用在A中定义过的方法,那么B中这个入参为B的show函数是不会被调用的,因为它是子类新有的方法。因此最终调用的是入参为A
类的show函数,因为它可以由多态的方式来接收参数b
,最终的输出为 B and A
多态方法调用的优先级
上边这个案例实际上还设计到方法调用的优先级问题,优先级由高到低依次为:
- this.show(O)
- super.show(O)
- this.show((super)O)
- super.show((super)O)
再看下边这个案例:
输出是ai ni
java中接口和类的区别
- 在java中不允许类的多继承,但是支持接口的多继承。
- 在接口中只能定义全局常量和抽象方法
- 某个接口被类实现时,在类中一定要实现接口中的抽象方法,而类的继承则可以直接使用父类的方法。
集合
线程安全的集合有哪些?线程不安全的呢?
线程安全的:
- HashTable:比HashMap多了个线程安全
ConcurrentHashMap:是一种高效但是线程安全的集合
- Vector:比ArrayList多了一个同步化机制
- Stack:栈也是线程安全的,它继承自Vector
线程不安全的:
- HashMap
- ArrayList
- LinkedList
- HashSet
- TreeSet
- TreeMap
List
ArrayList与LinkedList异同点
- 底层实现:ArrayList底层使用的是Object数组,LinkedList底层使用的是双向循环链表数据结构。
- 插入和删除是否受元素位置的影响:ArrayList采用数组存储,所有插入和删除元素的时间复杂度受元素位置的影响,而LinkedList采用链表存储,插入和删除元素的时间复杂度为O(n)
- 是否支持随机访问:ArrayList支持随机访问,LinkedList不支持随机访问。
- 内存空间占用:ArrayList的空间浪费体现在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在每个元素都需要存放直接前驱和直接后继以及数据。
ArrayList与Vector的区别
- Vector是线程安全的,ArrayList不是线程安全的,其中Vector在关键性的方法前面都加了synchronize关键字,来保证线程的安全。
- ArrayList在底层数组不够用时,在原来的基础上扩展0.5倍,Vector扩展1倍,使用ArrayList有利于节约内存空间。
Array和ArrayList的区别
- Array可以包含基本数据类型和对象类型,ArrayList只能包含对象类型,换言之,Array可以直接存放原始数据类型,而ArrayList只能存放基本数据类型的包装类。
- Array大小是固定的,ArrayList的大小是动态扩展的。
- ArrayList提供了更多的方法和特性,比如allAll(),removeAll(),iterator()等等。
Map
HashMap默认的负载因子是多少?为什么是0.75
HashMap的默认构造函数:
int threshold; // 容纳键值对的最大值
final float loadFactor; // 负载因子
int modCount;
int size;
Node[] table 的初始化长度length(默认是16),loadFactor默认值为0.75,threshold是HashMap所能容纳的键值对的最大值,threshold = length × load factor,也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
比如说当前的容器容量是16,负载因子是0.75,16*0.75=12,也就是说,当容量达到了12的时候就会进行扩容操作。
0.75是对空间和时间效率的一个平衡选择,一般不需要修改,除非在时间和空间比较特殊的情况下:
- 如果内存空间很多,时间效率要求比较高,可以较低负载因子loadFactor。
- 相反如果内存紧张,而时间效率要求不高,可以增大loadFactor,这个值也可以大于1.
HashMap中key的存储索引是怎么计算的?
首先根据key值计算出hashcode,然后根据hashcode计算出hash值,最后通过hash&(length - 1)计算得到实际存储的位置。
这里虽然用到的是&运算,但实际上还是在取模,因为length是2的幂,length - 1就是二进制位全部为1,与运算就能选择需要的低位数据
- 根据key计算hashcode
- 根据hashcode求hash
- 由hash取模(代码实现上是求&运算)得到实际存储位置索引。
HashMap的put方法流程
以JDK1.8为例:
- 根据key计算hashcode,再得到hash值,取模,找到元素在数组中的存储的下标。
- 如果数组为空,调用resize进行初始化。
- 如果没有哈希冲突,直接放在对应的数组下标里。
- 如果冲突了,且key已经存在,就覆盖掉value;
- 如果冲突了,发现该结点已经是红黑树,就将节点添加到这个树上;
- 如果冲突后是链表,判断该链表是否大于8,如果大于8且数据容量小于64,就进行扩容,如果链表节点大于8,并且数组容量大于64,就进行扩容,如果链表节点大于8,且数组容量大于64,将这个结构转换为红黑树,否则,链表插入键值对。
HashMap的扩容方法?
HashMap在容量超过了负载因子所定义的容量之后,就会扩容,java里的数组是无法自动扩容的,方法是将hashmap的大小扩大到原来的两倍,并将原来的对象放入到新的数组中。
先来看jdk1.7的源码:
void resize(int newCapacity) {Entry[] oldtable = table;int oldcapacity = oldtable.length;if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了return;}Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组transfer(newTable); //!!将数据转移到新的Entry数组里table = newTable; //HashMap的table属性引用新的Entry数组threshold = (int)(newCapacity * loadFactor);//修改阈值
}
transfer()方法将原有的Entry数组里的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) {Entry[] src = table; //src引用了旧的Entry数组int newCapacity = newTable.length;for (int j = 0; j < src.length; j++) { //遍历的Entry数组Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素if (e != null) {src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)do {Entry<K,V> next = e.next;int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置e.next = newTable[i]; //标记[1]newTable[i] = e; //将元素放在数组上e = next; //访问下一个Entry链上的元素} while (e != null);}}
}
newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头位置,这样先放在一个索引上的元素,最终会被放到Entry链的尾部。
jdk1.8做了两处优化
-
resize之后,元素的位置在原来的位置,或者原来的位置 + oldCap(原来哈希表的长度),不需要向jdk1.7那样,重新计算hash,只需要看看原来的hash值新增的那个bit是0还是1就好,是0的话索引没变,是1的话,索引变成了原索引+oldCap。
因为n变为了两边,索引会增加1个bit位
,这个设计非常巧妙,省去了重新计算hash值的时间。
-
JDK1.7中rehash的时候,旧链表迁移到新链表,如果新表的数组索引位置相同,则链表会倒置,因为采用的是头插法,JDK1.8不会倒置,使用的是尾插法。
一般用什么作为HashMap的key?
- 一般用Integer、String这种不可变类当做HashMap的key,而且String最为常用,因为字符串是不可变的,所以它在创建的时候hashcode就被缓存了,不需要重新计算。
- 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。
HashMap为什么线程不安全?
- 多线程下扩容死循环,JDK1.7中的HashMap使用头插法,在多线程的环境下,扩容的时候可能导致环形链表的出现,造成死循环,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程的put可能导致元素的丢失,多线程同时执行put操作,如果算出来的索引位置相同,可能导致前一个key被后一个key覆盖。
- put和get并发时,可能导致get为null。
ConcurrentHashMap
- HashMap是线程不安全的,因为HashMap中的操作没有加锁。HashTable是线程安全的,但是HashTable只是单纯的在put()方法上加上
synchronized
保证插入时阻塞其他线程的插入操作,效率低下。 - ConcurrentMap是线程安全的,ConcurrentMap并非锁住整个方法,而是通过原子操作和局部加锁的方法,保证了多线程的线程安全,尽可能减少了性能的损耗。
JDK1.7的concurrentHashMap
JDK1.7的ConcurrentHashMap,把哈希桶分成小数组(Segment),每个小组有n个HashEntry。其中Segment继承了ReentrantLock,所以Segment是一种可重入锁,扮演锁的角色,而HashEntry用于存储键值对数据。
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
JDK1.8的concurrentHashMap
JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树
结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized
实现更加低粒度的锁。
将锁的级别控制在了更细粒度的哈希桶元素级别,也就是说只需要锁住这个链表头结点(红黑树的根节点),就不会影响其他的哈希桶元素的读写,大大提高了并发度。
Collection框架实现比较要怎么做?
- 第一种:实体类中实现Comparable接口,并实现compareTo方法,称为内部比较器。
- 第二种,在创建集合时,指定一个比较器,实现Comparator接口的compare(T t1, T t2)方法。
线程并发
多线程基础
线程和进程的区别
- 根本区别:进程是操作系统资源分配的基本单位,而线程是处理任务调度和执行的基本单位。
- 资源分配:进程是操作系统中一个独立的执行单位,拥有独立的地址空间、文件描述符、内存空间等系统资源,而线程是进程的子任务,同一进程中的线程共享进程的资源,线程之间可以直接访问同一进程的数据。
- 切换开销:由于进程拥有独立的地址空间和系统资源,因此进程切换时,需要保存和恢复进程的上下文信息,会涉及到地址空间的页表切换、缓存失效处理,而线程的开销较少,因为线程共享相同的地址空间和系统资源,它的切换只需要保存和恢复自己的栈指针、程序计数器等。
- 并发性和多核利用:由于线程共享进程的资源,多个线程在同一时间内可以并发执行,提高了系统的并发性,而进程的并发性,进程间通信需要额外的机制,此外在多核系统上,多个线程可以并行执行。
什么是线程死锁?死锁产生条件?
- 多个线程同时被阻塞,它们中的一个或全部都在等待某个资源释放。
死锁必须具备以下四个条件:
- 互斥条件,存在互斥资源,任意时刻只能被一个线程占用。
- 请求与保持条件,一个线程因请求资源被阻塞时,对已经获取的资源保持,不释放。
- 不剥夺条件,进程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干的线程之间形成一种头尾相接的循环等待资源关系。
常见的对比
runnable vs callable
- callable可以返回结果或者抛出检查异常
- 如果任务不需要返回结果或者抛出异常,推荐使用runnable,可以使得代码更加简洁。
shutdown() vs shutdownNow()
- shutdown():关闭线程池,线程池的状态变为
SHUTDOWN
,线程池不再接受新任务,但是队列中的任务需要执行完毕。 - shutdownNow():关闭线程池,此时线程池的状态变为
STOP
,线程池会终止正在运行的任务,并停止处理排队的任务,并返回正在等待执行的List - shutdownNow()的工作原理是遍历线程池中的工作线程,然后逐个调用线程的
interrupt
方法来中断线程,所以无法响应中断的任务可能无法终止。
isTerminated() vs isShutdown()
- isShutDown 当调用了showdown后,返回true
- isTerminated当调用shutdown()方法,并且所有提交的任务完成后返回true
sleep() vs wait
- sleep是Thread的方法,目的是让程序暂停执行一会儿,而wait()是Object的方法,是一种线程之间的同步机制,只能在synchronize同步代码块或者同步方法中使用。
- sleep方法不会释放锁,wait方法会释放锁。
CAS(compare and swap)
CAS:全称compare and swap
,它是一条CPU同步原语,是一种硬件对并发的支持,针对多处理器操作而设计的一种特殊指令,用于管理对共享数据的并发访问。
- CAS是一种无锁的非阻塞算法的实现,它包含了3个操作数:
- 需要读写的内存值V
- 旧的预期值A
- 要修改的更新值B
- 当且仅当V的值等于A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(他的功能是判断内存某个位置是否为预期值,如果是则更改为新的值,这个过程是原子的)
CAS的缺陷:
- ABA问题:并发环境下,假设初始条件是A,去修改数据时发现是A就会执行修改,但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况,此A已经非彼A,数据即便修改成功,也可能有问题。可以通过atomic stamped reference解决ABA问题,它是一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
- 循环时间长:自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销,CAS是自旋锁的实现基础,通常自旋锁用于资源占用较短的场景,就是为了避免这个耗时问题。
- 只能保证一个变量的原子操作。CAS目前只能保证一个变量的执行操作的原子性。
synchronize
synchronize和volatile的区别是什么
- volatile解决的是内存可见性问题,会使得所有对volatile变量的读写都直接写入到主存,即保证了变量的可见性。
- synchronize解决的事件控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让当前对象中被synchronize关键字保护的代码段无法被其他线程访问,也就无法并发执行。而且synchronize还会创建一个内存屏障,内存屏障指令保证了所有CPU的操作结果直接刷到主存中,从而保证了操作的可见性。
两者的区别主要有如下:
- volatile本质是在告诉JVM当前变量在寄存器中的值是不确定的,需要从主存中读取;synchronize则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile仅能使用在变量级别,synchronize则可以使用在变量、方法和类的级别。
- volatile仅能实现变量修改的可见性,不能保证原子性,而synchronize保证了变量的修改可见性和原子性。
- volatile不会造成线程阻塞,synchronize可能会造成线程阻塞
- volatile标记的变量不会被编译器优化,synchronize标记的变量可以被编译器优化
synchronized和Lock有什么区别?
- synchronized可以给类,方法,代码块加锁,而lock只能给代码块加锁。
- synchronized不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而lock需要自己加锁和释放锁,如果使用不当没有unLock(),会造成死锁。
- 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
synchronized和reentrantLock区别是什么
-
两者都是可重入锁
可重入锁:也叫做递归锁
,可重入锁指的是在一个线程中可以多次获取同一把锁,比如:一个线程在执行一个带锁的方法,该方法又调用了另一个需要相同锁的方法,则线程可以直接执行调用的方法,而无需重新获得锁。
两者都是同一个线程每进入一次,锁计数器自增1,等待锁计数器降为0,释放锁。 -
synchronized依赖于JVM,而reentrantlock依赖于API
- synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
- ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally语句块来完成)
synchronize底层实现原理
synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置
。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)
的持有权。其内部包含一个计数器
,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令
后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED,访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
多线程中 synchronized 锁升级的原理是什么?
synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候
threadid 为空,jvm 让其持有偏向锁
,并将 threadid 设置为其线程 id,再次进入的时候会先判断
threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级 锁
,通过自旋循环一定次数来获取锁
,执行一定次数之后,如果还没有正常获取到要使用的对象,此时
就会把锁从轻量级升级为重量级锁
,此过程就构成了 synchronized 锁的升级。
锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。
在 Java 6 之后优化 synchronized 的实现方
式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
ThreadLocal
线程池
使用线程池的好处
池化技术:线程池、数据库连接池、http连接池
池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
线程池提供了一种限制、管理资源的策略。每个线程池还维护一些基本统计信息,例如已经完成任务的数量。
使用线程池的好处:
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗
- 提高响应速度:当任务到达时,可以不需要等待线程创建就能立即执行。
- 提高线程的可管理性,线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配、监控和调优。
线程池大小确定
- CPU密集型任务(N + 1):这种任务消耗的主要是CPU资源,可以将线程数设置为N(
CPU核心数
)+ 1,比CPU多出来一个线程是为了防止线程偶发的缺页中断
,或者其他原因导致的任务暂停
而带来的影响,一旦任务停止,CPU就会处于空闲状态,这种情况下,多出来的这一个线程就可以充分利用CPU的空闲状态。
- I/O密集型(2N):这种任务应用起来,系统大部分时间用来处理I/O交互,而线程在处理I/O的时间段内不会占用CPU来处理,这时就可以将CPU交给其他线程使用,因此在I/O密接型任务,可以配置多一些线程,比如2N。
线程池执行任务的流程
- 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
- 当任务大于核心线程数corePoolSize,向阻塞队列添加任务。
- 如果阻塞队列已经满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数据大于maximumPoolSize,说明当前设置的线程池中线程已经处理不了,就会执行饱和策略。
线程池常用的阻塞队列有哪些
-
固定线程池和单例线程池,默认使用的阻塞队列是容量为Interger.MAX_VALUE的LinkedBlockingQueue,可以认为是无界队列。由于FixedThread的线程数固定,所有当任务特别多时,需要一个没有容量限制的阻塞队列存放任务。
-
SynchronusQueue
缓存线程池使用的阻塞队列为SynchronousQueue,缓存线程池的最大线程数是Interger的最大值,可以认为线程数是无限扩展的,所以缓存线程池的情况与上边正好相反,因为一旦有任务就可以创建新的线程,而不需要额外保存。 -
DelayedWorkQueue
第三种阻塞队列是DelayedWorkQueue,它对应的线程池分别是 ScheduledThreadPool 和SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。
DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和SingleThreadScheduledExecutor 选择DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。
JVM
内存
JVM内存结构是怎样的?
jvm将虚拟机分为5大区域,程序计数器、虚拟机栈、本地方法栈、java堆、方法区;
- 程序计数器:
线程私有
,是一块很小的内存空间,作为当前线程的执行的代码行号指示器,用于记录当前虚拟机正在执行的线程指令地址。 - 虚拟机栈(Java栈):
线程私有
,每个方法执行的时候创建一个栈帧,用于存储局部变量表,操作数、动态链接和方法返回等信息,当栈深度超过了虚拟机允许的最大深度,就会抛出StackOverFlowError
- 本地方法栈:
线程私有
,保存的是native方法的信息,当一个JVM创建的线程调用native方法后,jvm不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法。 - 堆:所有线程共享的一块内存,几乎所有对象的实例和数组都在堆上分配内存,因此堆区经常发生垃圾回收操作。
- 方法区:存放已经加载的类信息,常量、静态变量、即时编译器编译后的代码数据。jdk1.8中不存在方法区了,被元数据区替代了,原方法区被分成两部分:1、加载类信息。2、运行时常量池。加载类信息保存在元数据区,运行时常量池保存在堆中。
谈谈对OOM的认识,如何排查OOM问题
除了程序计数器,其他内存区域都有OOM(out of memory, 内存溢出)的风险。
- 栈一般经常发生StackOverFlowError,比如32位系统,单进程限制2G内存,无限创建线程就发生栈的OOM
- 堆内存溢出:GC之后无法在堆中申请内存创建对象。
- 方法区OOM:经常会遇到的是动态生成大量的类,jsp等
- 直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请。
排查OOM的方法:
- 增加两个参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,当 OOM 发生时自动 dump 堆内存信息到指定目录;
- jstat查看监控JVM的内存和GC情况,先观察问题大概出现在什么区域。
- 使用MAT工具载入到dump文件,分析大对象的占用情况,比如HashMap做缓存未清理,时间长了就会内存溢出,可以改为弱引用。
谈谈JVM中的常量池
JVM常量池主要分为Class文件常量池、运行时常量池、全局字符串常量池、以及基本数据类包装类对象常量池。
Java6和6之前,常量池是存放在方法区(永久代)中的。
Java7,将常量池是存放到了堆中。
Java8之后,取消了整个永久代区域,取而代之的是元空间
。运行时常量池和静态常量池存放在元空间中
,而字符串常量池依然存放在堆中。
- class文件常量池:lass文件是一组以字节为单位的二进制数据流,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。(jdk1.8前处于方法区,1.8处于元空间)
- 运行时常量池:在运行时可以通过代码生成常量并将其放入运行时常量池中。这种特性被用的最多的就是String.intern(),
- 全局字符串常量池:JVM所维护的一个字符串实例的引用表
- 基本类型包装类对象常量池:java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外
两种浮点数类型的包装类则没有实现
。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池
,也即对象不负责创建和管理大于127的这些类的对象。
如何判断一个对象是否存活?
分为两种算法:
- 引用计数法:给每一个对象设置一个引用计数器,当有一个地方引用该对象的时候,引用计数器就+1,引用失效时,引用计数器就-1;当引用计数器为0的时候,就说明这个对象没有被引用,也就是垃圾对象,等待回收;缺点:无法解决
循环引用的问题
,当A引用B,B也引用A的时候,此时AB对象的引用都不为0,此时也就无法垃圾回收,所以一般主流虚拟机都不采用这个方法; - 可达性分析法:
从一个被称为GC Roots的对象向下搜索,如果一个对象到GC Roots没有任何引用链相连接,说明此对象不可用,在java中可以作为GC Roots的对象有以下几种。- 虚拟机栈中引用的对象
- 方法区类静态属性引用的变量
- 方法区常量池引用的对象,比如字符串常量池
- 本地方法栈JNI引用的对象
java的四种引用是什么?
- 强引用,普通对象引用关系,如String s = new String(“ConstXiong”)
- 软引用(SoftReference),用于维护一些可有可无的对象。在内存不足时,系统会回收软引用对象,如果回收了软引用对象之后依旧没有足够内存,才会抛出内存溢出异常。
- 弱引用(WeakReference),相比软引用,要更加无用一些,它拥有更短的生命周期,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
- 虚引用(PhantomReference),是一种形同虚设的应用,现实场景中用的不多,主要是用来跟着对象被垃圾回收的活动。
GC(Garbage Collection,垃圾收集)
JVM的垃圾回收算法有哪些?
- 标记清除(mark sweep)-位置不连续,产生碎片,效率偏低
- 拷贝算法(copying)没有碎片,浪费空间
- 标记压缩(mark compact)没有碎片,效率偏低
类加载
什么是类加载?类加载的过程?
虚拟机把描述类的数据加载到内存里,并对数据进行校验、解析和初始化
,最终变成可以被虚拟机直接使用的class对象。
类的整个生命周期包括:
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)
什么是类加载器,常见的类加载器有哪些?
类加载器是指:通过一个类的全限定性类名获取该类的二进制字节流叫做类加载器;类加载器分为以下四种:
- 启动类加载器(BootStrapClassLoader):用来加载java核心类库,无法被java程序直接引用;
- 扩展类加载器(Extension ClassLoader):用来加载java的扩展库,java的虚拟机实现会提供一个扩展库目录,该类加载器在扩展库目录里面查找并加载java类;
- 系统类加载器(AppClassLoader):它根据java的类路径来加载类,一般来说,java应用的类都是通过它来加载的;
- 自定义类加载器:由java语言实现,继承ClassLoader;
什么是双亲委派机制?
双亲委派机制,是按照加载器的层级关系,逐层进行委派。例如要加载一个类MyClass.class,从低层级到高层级一级一级委派,先由应用层加载器委派给扩展类加载器,再由扩展类委派给启动类加载器;启动类加载器载入失败,再由扩展类加载器载入,扩展类加载器载入失败,最后由应用类加载器载入,如果应用类加载器也找不到那就报ClassNotFound异常了。
双亲委派机制的优点:
- 保证安全性,层级关系代表了优先级,也就是所有类的加载,优先给启动类加载器,这样就保证了核心库类。
- 避免重复,如果父类加载器加载过了,子类加载器就没有必要再去加载了。