Java相关

一、Java基础

#separator:tab #html:true #tags column:3

Java面向对象的特点

+

重载和重写有什么区别

+
抽象类和接口有什么区别

"

①Java面向对象的特点

Ⅰ. 封装:将数据和方法封装在对象内部,隐藏实现细节,只对外暴露必要的接口;——提高代码的安全性和可靠性
Ⅱ. 继承:允许子类继承父类的属性和方法,子类可以定义自己的属性和方法,也可以通过重写来修改父类的方法;——提高了代码的复用性和可维护性
Ⅲ. 多态:允许不同对象对同一消息作出不同的响应。在Java中,多态性通过方法重载和重写来实现——提高了代码的灵活性和扩展性
 

②重载和重写有什么区别

Ⅰ. 重载是同一个类中的同名方法,但参数列表不同(对返回值、抛出的异常、权限修饰符没有要求);
Ⅱ. 重写是子类重写父类的方法,同名同参数列表,且子类返回值类型、抛出的异常必须比父类更小或相等,访问权限必须比父类更大或相等;(如果方法的返回类型是 void 和基本数据类型,则返回值重写时不可修改)
Ⅲ. 重载发生在编译期,而重写发生在运行期;
 
 
③抽象类和接口有什么区别?
从语法层面来说,抽象类是类,可以包含各种类型的成员变量、提供了具体实现的方法、以及构造器、静态代码块;
而接口中成员变量只能是编译时常量(public static final),只能有抽象方法(jdk1.8之后允许包含有方法体的默认方法),不能有构造器和静态代码块;
除此之外,一个类只能继承一个抽象类,但可以实现多个接口;
从设计层面来说,抽象类是对整个类包含属性和行为的抽象,而接口是对类局部也就是行为的抽象;
(抽象类:可以包含abstract修饰的抽象方法,子类如果不是抽象类就必须实现全部抽象方法)
 

" Java基础

面向对象和面向过程的区别

"

面向过程把解决问题的过程拆成一个个方法,通过一个个方法的执行解决问题;
面向对象会先抽象出对象,然后用对象执行方法的方式解决问题;
面向对象开发的程序一般更易维护、易复用、易扩展;

" Java基础

Java 中的几种基本数据类型了解么?

+
为什么像 byteshortintlong能表示的最大正数都减 1 了?
+
为什么计算机中要使用补码?

"

Java 中有 8 种基本数据类型,分别为:
  • 6 种数字类型:
    • 4 种整数型:byteshortintlong
    • 2 种浮点型:floatdouble
  • 1 种字符类型:char
  • 1 种布尔型:boolean
 
为什么像 byteshortintlong能表示的最大正数都减 1 了?
因为在计算机中以二进制补码来表示整型,其中首位是用来作为符号位的,0表示正数,1表示负数,
当符号位为0,数值位也全为0时表示0,而当符号位为1时,数值位全为0时表示-2^n,所以0开头的正数有一个用来表示0了,所以比负数少了一个;

" Java基础

基本数据类型和包装类型的区别

+

包装类型的缓存机制/常量池技术了解吗?

+
Object类包含哪些方法?
 

"

①基本数据类型和包装类型的区别

Ⅰ. 用途不同:包装类型可以用于泛型,而基本数据类型不可以;
Ⅱ. 占用空间不同:基本数据类型占用空间比包装类型小;
Ⅲ. 比较方式不同:基本数据类型用==比较的是值,而包装类型比较的是对象的内存地址,要想比较包装类型的值应当用equals()方法;
Ⅳ. 默认值不同:基本数据类型中数值的默认值是0,boolean连续的默认值是false,而包装类型属于引用类型,默认值是null;
 

②包装类型的缓存机制/常量池技术了解吗?

包装类型的常量池技术指的是Byte、Short、Integer、Long这四种整型的包装类默认创建了[-128, 127]的缓存数据,Character创建了数值在[0, 127]的缓存数据,Boolean直接返回TURE或FALSE,
对于在上述范围内的包装类型,会直接从缓存中返回对应的对象,无需创建新的对象,只有在超出范围或是手动new一个对象时,才会创建新的对象;
 
 
③Object类包含哪些方法?
toString():返回对象的字符串表示;
hashCode():返回对象的哈希码;
equals():判断对象是否相等;
clone():返回对象的副本,又分为深拷贝和浅拷贝;
getClass():返回对象的运行时类;
wait()、notify()、notifyAll():用于线程间的同步;
 
 

" Java基础

自动装箱与拆箱了解吗?原理是什么?

+

了解自动拆箱引发的NPE(Null Pointer Error 空指针异常)问题吗?

"

④自动装箱与拆箱了解吗?原理是什么?

自动拆装箱是一种语法糖,
其中自动装箱指的是将基本数据类型转换成对应的包装类型,实际上是调用了对应包装类型的静态方法Xxx.valueOf(),例如Integer a = Integer.valueOf(1)
自动拆箱指的是将包装类型转换成对应的基本数据类型,实际上是调用了包装类型的xxxValue()方法,例如Integer a = 1;  int b = a.intValue();
 

⑤了解自动拆箱引发的NPE(Null Pointer Error 空指针异常)问题吗?

因为自动拆箱实际上就是调用了包装类型的xxxValue()比如intValue()方法,所以当调用者为null时,就会引发空指针异常,
比如在三目运算符中,当一个表达式为基本数据类型,另一个为包装类型时,就会触发自动拆箱,此时若包装类型为空,就会触发空指针异常,
解决办法是尽量让三目运算符中的两个表达式都是包装类型、或是都是基本数据类型,这样就不会触发自动拆箱了;

" Java基础

== 和 equals() 的区别

+
equals()方法具体逻辑是什么?
+
重写equals()方法一定要重写hashCode()方法吗?

"

①使用==判断是否相等:对于基本数据类型,判断的是数值,对于引用类型,判断的是对象的内存地址;
 
②使用equals()方法判断引用类型是否相等:
对于重写了equals()方法的类,会使用重写的equals()方法进行比较,通常都是对类中的各个属性的值进行比较,
对于没有重写equals()方法的类,则会使用父类Object的equals()方法进行比较,实际上就是使用==比较对象的内存地址;
 
 
equals()方法具体逻辑是什么?
首先使用==判断是否是同一个对象,如果是直接返回true;
然后使用instanceof判断是否是当前类的实例,如果不是直接返回false;
之后依次判断两个对象的各个属性是否相等,基本数据类型直接使用==,引用数据类型使用equals()方法比较,如果全部相等就返回true,反之返回false;
 
 
重写equals()方法一定要重写hashCode()方法吗?
是的,因为在Java中如果两个对象使用equals()方法判断相等,则其使用hashCode()方法获得的哈希值也必须相等,这是哈希表等数据结构正常运行的前提条件,
因为这些数据结构中通过计算key的哈希值并与(容量-1)做与运算来得到其在底层数组中的索引下标,所以必须保证同一个对象计算得到相同的哈希值,这样才能保证相同的key值落在数组中的相同位置;
 

" Java基础

深拷贝和浅拷贝区别了解吗?引用拷贝呢?

——口头陈述完,请手写代码演示一下浅拷贝和深拷贝

"

首先说引用拷贝,顾名思义就是不创建新的对象,复制引用;
 
然后说浅拷贝,浅拷贝会创建一个新的对象,但对于其中引用类型的成员变量,则只会复制其引用,
因此新对象和原始对象中该属性实际指向同一对象,如果原始对象中该属性发生改变,新对象也会随之改变;
 
再说深拷贝:深拷贝则是对于引用类型的成员变量也会进行拷贝并创建新的对象,而不是仅仅复制引用;
因此新对象中和原始对象中引用类型的属性并非同一对象,原始对象中该属性发生改变,不会导致新对象的改变;

" Java基础 "

String、StringBuffer、StringBuilder 的区别?

+

String 为什么是不可变的?

+
 Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?
+

用“+” 实现字符串拼接的原理?

"

①String、StringBuffer、StringBuilder 的区别?

Ⅰ. 可变性:String是不可变的,StringBuilder、StringBuffer都是可变的;
Ⅱ. 线程安全性:String具有不可变性,所以线程安全,StringBuffer加了同步锁,也是线程安全的,StringBuilder没有加同步锁,所以是线程不安全的;
Ⅲ. 性能:StringBuilder > StringBuffer > String;
一般来说,如果操作少量数据,可以使用String,如果操作大量数据,单线程情况下使用StringBuilder,多线程情况下使用StringBuffer;
 

②String 为什么是不可变的?

首先,String类本身由final修饰,不可继承,无法通过创建子类来破坏其不可变性;
其次,String内部用于保存字符串的数组value也被final修饰,且String内部没有提供任何可以改变value值的方法;
 
 Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?
新版的 String 通常采用 Latin-1编码方案,在此编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间
 

③用“+” 实现字符串拼接的原理?

使用+进行字符串拼接实际上就是创建一个StringBuilder,然后调用其append()方法实现拼接,最后再调用toString()方法转换为字符串;

Java基础

什么是字符串常量池?有什么作用?

+

字符串的intern()方法的作用?

"

④什么是字符串常量池?有什么作用?

字符串常量池StringTable是JVM在堆中开辟的一块内存区域,用于存储对字符串常量的引用,可以提升性能和减少内存消耗;
——只要是以字符串常量形式出现过,或是手动调用了intern()方法的的字符串,都会自动在StringTable中添加对其的引用;
 

⑤字符串的intern()方法的作用?

对一个字符串对象的引用使用intern()方法,其作用是:
若StringTable中存在内容和该对象相同的字符串的引用,则直接返回StringTable中的引用;
若不存在,则将该引用放入StringTable中并返回;
 
@Test
    public void test2(){
        String s1 = ""ab"";
        //对""ab""的引用已存在于字符串常量池中
        String s2 = s1.intern();
        //true——s1和s2都是字符串常量池中的引用
        System.out.println(s1 == s2);

        String s3 = new String(""cd"");
        //对""ab""的引用已存在于字符串常量池中
        String s4 = s3.intern();
        //fasle————s3是对堆中新建的内容为""ef""的字符串的引用,s4则是字符串常量池中的引用
        System.out.println(s3 == s4);

        String s5 = new String(""e"") + new String(""f"");
        //由字符串变量的拼接可知,对拼接结果""ef""的引用不存在于字符串常量池中,故此时直接将s5放入字符串常量池中(不新建对象),同时将s5作为返回值
        String s6 = s5.intern();
        //true
        System.out.println(s5 == s6);
        //true
        System.out.println(s5 == ""ef"");
    }

" Java基础 什么是反射?优缺点?
+
常用的获取Class实例的方式?
+
反射中常用的方法: "什么是反射?优缺点?

通过反射可以在运行时获取一个类的属性、方法、构造器、接口等,并进行调用;
反射的优点是让代码更加灵活,Spring框架中大量使用了反射机制,jdk动态代理也是基于反射实现的(通过反射获取被代理类的方法并调用);
缺点则是会增加安全问题;
 
 
常用的获取Class实例的方式?
常用的获取Class实例的三种方式:
类.class、
对象.getClass()、
Class.forName(字符串类型的类路径,如""com.atguigu.java.Person"");
 
 
反射中常用的方法:
getFields():获取当前运行时类及其父类中声明为public访问权限的属性;
getDeclaredFields():获取当前运行时类中声明的所有属性。(不包含父类中声明的属性);
getMethods():获取当前运行时类及其所有父类中声明为public权限的方法;
getConstructors():获取当前运行时类中声明为public的构造器;
setAccessible():对获取到的属性/方法/构造器对象调用.setAccessible(true)方法,即可保证其可访问;

" Java基础 什么是泛型?
+
泛型的使用方式有哪些?
+
可以创建泛型数组吗?
+
泛型擦除机制是什么? "什么是泛型?
使用泛型参数可以编写多种数据类型的通用代码,并且提供了类型检查和自动类型转换的功能;


泛型的使用方式有哪些?
一般有泛型类、泛型接口、泛型方法;

public class Generic<T>{ private T key;}
public interface Generator<T> { public T method(); }
public static < E > void printArray( E[] inputArray ){}


可以创建泛型数组吗?
java中不能直接创建泛型数组,因为java中数组在创建时需要知道元素的基本类型,而泛型在编译时会被擦除,因此无法直接创建泛型数组;
不过可以通过使用类型转换,先创建一个Object数组,再用泛型强转,这种方式可能会强转失败抛出异常;
还可以像ArrayList那样,底层直接使用Object数组存储数据,但对插入元素进行泛型参数的限制;


泛型擦除机制是什么?
泛型擦除指的是Java在编译期间,编译器会动态地将泛型T擦除并替换为Object,
作用是为了保证引入泛型机制但不创建新的类型,减少虚拟机的运行开销;
(从某种意义上说,Java 实际上并没有真正实现泛型。Java 中的泛型只是基于面向对象语言共有的允许“向上引用”的语法而已。因为 Java 中所有的对象都直接或间接继承自 Object 类,所以基于这种技术的泛型变得可行。也因为这样,在 Java 中的很多情况下,可以使用 Object 类来代替使用泛型。)" Java基础 Exception和Error有什么区别?
+
try-catch-finally有什么用?如果在finally中使用return语句会发生什么?
+
throws和try-catch有什么区别? "Exception和Error有什么区别?
在Java中所有异常都有一个共同的父类Throwable,Throwable有两个子类:Exception和Error:
Exception是程序本身可以处理的异常,可以使用try-catch来捕获和处理,Exception又分为:
       Checked Exception受检查异常:这种异常如果没有被throws、try-catch处理,会无法通过编译,除了RuntimeException及其子类异常之外,都属于受检查异常,例如InterruptedException被中断异常;
       Unchecked Exception不受检查异常:这种异常即使不处理也可以正常通过编译,包括各种RuntimeException及其子类,例如空指针异常NullPointerException;
Error则是程序无法处理的异常,例如OutOfMemoryError内存溢出,当发生这些异常时,JVM通常会直接让线程终止;


try-catch-finally有什么用?如果在finally中使用return语句会发生什么?
try代码块可以捕获异常,catch代码块处理异常,finally代码块不论是否发生异常都会执行;
当 try 代码块和 finally 代码块中都有 return 语句时,try 代码块中的 return 语句会被忽略,因为finally代码块会在try-catch代码块的return语句之前执行;
finally代码块也不是一定会执行,如果JVM终止运行,或是线程被终止的话就不会执行了;


throws和try-catch有什么区别?
①throws只能将异常抛给方法的调用者,并没有真正地将异常处理掉,
而try-catch则可以捕获异常并进行处理;
②throws在出现异常时,会抛出异常,异常代码之后的代码就不会执行了,
而try-catch可以在finally代码块中添加即使发生异常也会执行的代码;" Java基础 什么是幂等,如何实现?
+
"什么是幂等,如何实现?
幂等指的是一个操作无论执行多少次,结果都是一致的,也就是说幂等操作重复执行不会产生额外的影响;
幂等的实现方法:
①使用唯一标识符:例如MQ中为了避免消息被重复消费,需要保证消费消息这一操作的幂等性,解决办法是可以给每条消息添加一个唯一标识并存入Redis,在进行消费前先检查此标识是否存在于Redis中,如果已存在说明已被消费过,则不再重复消费直接返回,如果不存在,说明尚未消费过,则正常进行消费并将标识存入Redis中;
②使用乐观锁:配合版本号/时间戳,保证一个操作只能成功进行一次,后续相同操作无法重复进行;


Java 8新特性
①Lambda表达式:是JDK8中的一个语法糖,可以对匿名内部类的写法进行优化,让函数式编程只关注数据而不是对象;

" Java基础

 

 

 

 

二、Java集合

#separator:tab #html:true #tags column:3 "

 ArrayList基本介绍

+

 ArrayList扩容机制
+
 ArrayList中get()、set()、add()、remove()操作的时间复杂度
+
 ArrayList的最大容量

" Ⅰ. 基本介绍:

ArrayList底层就是一个Object[]数组,通过在合适的时机对数组进行扩容,实现动态容量的数组,线程不安全;
 

Ⅱ. ArrayList扩容机制:

以空参构造器创建ArrayList时,实际分配的是一个空的默认数组,在放入第一个元素时,会将数组容量扩充为10,
之后当元素数量超过数组当前容量时,会先将数组容量扩充至1.5倍(old + old >> 2),——add()方法添加元素这一步就够了
如果仍不够,则直接将数组容量扩充至可容纳全部元素的容量,——addAll()方法添加元素可能就需要这一步
也可以使用ensureCapacity()方法手动将容量扩充至需要的大小;
 
Ⅲ. ArrayList中get()、set()、add()、remove()操作的时间复杂度:
支持随机访问,故get()操作的时间复杂度为O(1),set()也是O(1);
如果是尾部插入/删除,则无需进行数组拷贝操作,时间复杂度为O(1);
而如果是头部或是中间位置插入/删除,则必须进行数组拷贝操作,时间复杂度为O(n);
 
Ⅳ. ArrayList的最大容量
ArrayList的默认最大容量是Integer.MAX_VALUE - 8,
因为ArrayList的容量size是int类型的,故最大可表示为Integer.MAX_VALUE,
又因为在有些虚拟机中例如HotSpot虚拟机还需要32字节来存储对象头信息,所以需要预留8个int的空间来存放这些对象头信息,
不过实际在扩容时是可以将ArrayList的最大容量扩充到Integer.MAX_VALUE的,只是有可能会导致OutOfMemoryError;

Java集合

ArrayList和LinkedList的区别——其实就是数组和双向链表的区别

Ⅰ. 线程安全性:都不保证线程安全;
Ⅱ. 底层数据结构:ArrayList底层通过Object[]数组实现,支持随机访问,LinkedList底层通过双向链表实现,不支持随机访问,二者都可以存入null值(可以但不推荐);
Ⅲ. 增删查改时间复杂度:ArrayList在尾部增删、或是任意位置的查改操作都是O(1)时间复杂度,在头部或是指定位置增删操作都是O(n)时间复杂度,因为需要移动后面的元素;
LinkedList除了在头尾部的增删操作是O(1)时间复杂度,在指定位置增删、以及查改操作都是O(n)时间复杂度;
Ⅳ. 内存占用:ArrayList需要在结尾预留一定空间,LinkedList每个节点都要花费额外空间存储前驱、后继结点地址;

Java集合 "

PriorityQueue基本介绍

+
底层数据结构

" Ⅰ. 基本介绍

总是让优先级最高的元素排在队头,线程不安全,不支持存储null和不可排序的对象;
 

Ⅱ. 底层数据结构

底层使用Object[]数组存储数据;
通过建立二叉堆(大根堆或小根堆),使得优先队列在添加、删除元素之后,总能以O(logn)的时间复杂度再次选出优先级最高的元素;
默认使用小根堆(每次弹出最小值),也可以通过接收Comparator参数来自定义优先级规则(o1 - o2递增,小根堆,o2 - o1递减,大根堆),

Java集合 "

介绍一下HashMap?底层数据结构是什么?

+
为什么不一开始就用红黑树,而是链表长度达到8时才转为红黑树?
+

为什么要引入红黑树,而不用其他树?

+

HashMap会出现红黑树一直增高变成无限高的情况吗?介绍一下HashMap的扩容机制

+
为何HashMap的容量必须是2的幂次方?

" "Ⅰ. 基本介绍&底层数据结构:
HashMap是Java中用于存储键值对的数据结构;

在 JDK 1.7 之前, HashMap 底层数据结构是数组和链表
在 JDK 1.8 之后,变成了数组和链表以及红黑树,当一个链表的长度超过8,且哈希表中数组长度达到64的时候就转换为红黑树,因为红黑树查找的时间复杂度O(log n),效率更高;
 
Ⅱ. 为什么不一开始就用红黑树,而是链表长度达到8时才转为红黑树?
因为在红黑树的查找效率在节点数量较少时相比单链表也没有明显优势,而红黑树节点更复杂,占用空间比链表节点更大,如果一开始就用红黑树会导致哈希表占用内存大大增加,
当单链表长度达到8,且哈希表中数组长度达到64时,红黑树O(logn)的查找效率明显超过了单链表的O(n),即使多占用一些空间也是值得的,这体现了时间和空间平衡的思想;
 
Ⅲ. 为什么要引入红黑树,而不用其他树?
  • 为什么不使用二叉排序树?二叉排序树在添加元素的时候极端情况下会出现线性结构,导致查询时间增长为O(N),因此不用二叉查找树。
  • 为什么不使用平衡二叉树呢?红黑树查询时间与AVL接近,但维护成本低于AVL。因为红黑树不追求""完全平衡"",而AVL是严格平衡树,因此在增加或者删除节点的时候,AVL的旋转的次数往往比红黑树要多。
 
Ⅳ. HashMap会出现红黑树一直增高变成无限高的情况吗?介绍一下HashMap的扩容机制
不能无限增长。当集合中的节点数超过了阈值threshold,HashMap会进行扩容,这时原始的红黑树节点会被打散,可能会退化成链表结构;
当哈希表中元素数量 > threshold时,触发扩容机制,每次将容量扩充为原来的两倍(添加元素时触发的扩容虽然条件不一样,但执行的操作是一样的),具体步骤如下:
首先生成一个容量为原来两倍的新数组,然后遍历老数组,
  对于单个节点,直接计算新索引并放入新数组即可;
  对于链表节点,顺序遍历链表并将其分成两个子链表,其中一个链表的头节点在新数组中的索引与在旧数组中一样,后一个链表的头节点在新数组中的索引=原索引+扩充的容量,分别将链表链表放入新数组中即可;
  对于红黑树节点,用next指针顺序遍历红黑树中的节点,同样分成索引不变和索引=原索引+扩充的容量两部分,同时统计两部分节点的数量,若数量小于等于6,就以将这些节点转换为链表存入新数组,若数量大于6,则仍以红黑树的形式放入新数组;
 
Ⅴ. 为何HashMap的容量必须是2的幂次方?
因为在HashMap中散列函数计算索引是用key的hashCode与(容量 - 1)做按位与运算,为了保证各元素在哈希表中均匀分布,必须保证哈希表的容量是2的幂次方;
(为什么不用hashCode对容量取余作为索引?因为位运算的效率比取余运算高得多)

" Java集合 "

HashMap是线程安全的吗?存在哪些问题?

+
ConcurrentHashMap是如何保证线程安全的?
+
为什么ConcurrentHashMap 的 key 和 value 不能为 null,而HashMap却可以?

" "

Ⅰ. HashMap是线程安全的吗?存在哪些问题?

不是线程安全的。
  • JDK 1.7 HashMap在插入链表节点时使用的是头插法,在多线程情况下可能会形成环形链表,导致查询列表时死循环;
  • JDK 1.8 HashMap 改成了尾插法,不会导致死循环,但在多线程情况下仍可能导致数据覆盖的问题,多线程环境下推荐使用ConcurrentHashMap;
 
Ⅱ. ConcurrentHashMap是如何保证线程安全的?
在jdk1.7中,将底层数组分为多个Segment,Segment继承自ReentrantLock,通过给每个分段加锁来保证线程安全,最多同时允许16个线程并发访问;
在jdk1.8中,取消了Segment分段锁,改为通过CAS操作,或是使用synchronized和对链表/红黑树的首节点上锁来保证线程安全。
  具体来说,在执行put()操作时,若节点为空,则使用CAS操作添加节点,若节点不为空,则先使用synchronized给首节点上锁,然后再向链表/红黑树中添加节点;
  在执行get()操作时无需获得锁,直接访问即可;
 
Ⅲ. 为什么ConcurrentHashMap 的 key 和 value 不能为 null,而HashMap却可以?
当一个线程对某个key执行get()操作,返回了null值,那么有两种情况:一是该键值不存在,二是该键值对就是存入了null值,
在单线程情况下,可以再执行一次containsKey()方法,根据底层数组中哈希值对应下标处Node节点是否存在来进一步判断该键值对是否存在,
而在多线程情况下,如果还需要再调用一次containsKey()方法,那么在这两次方法之间,可能会有其他线程进行了put()或是remove()操作修改了该节点,此时得到的结果就变成了修改后的状态,但我们期望的是修改前的状态,
想要确保得到我们期望的结果,就需要给两次操作加锁,但这无疑会降低程序的性能,所以在设计ConcurrentHashMap时选择了不允许向其中存入null值;
 
——经测试,HashMap可以直接将null值作为key或value存入,甚至键值对均为null也可以,不过由于key的唯一性,为null的key只能有一个,之后也可以正常进行get操作;
但ConcurrentHashMap则不允许key或value为null值,从源码中可以看到,当put操作的键值对中key或value为Null时,会直接报空指针异常;

" Java集合 "

LinkedHashMap基本介绍

+
如何开启LinkedHashMap的按照访问顺序排序的特性
+
如何使用LinkedHashMap实现LRU缓存(推荐算法题的实现)

" Ⅰ. 基本介绍:

LinkedHashMap是HashMap的一个子类,在继承了HashMap的全部属性和方法的同时,维护了一条双向链表,
使得LinkedHashMap支持按照元素插入顺序,或是访问顺序进行排序,可以利用这一特性实现LRU缓存;
性能方面,LinkedHashMap因为要维护一条双向链表,因此插入效率比HashMap差,但迭代遍历效率高于HashMap;
 

Ⅱ. 如何开启LinkedHashMap的按照访问顺序排序的特性

使用全参构造器创建LinkedHashMap对象,并将accessOrder参数设为true即可(默认为false,即按插入顺序访问);

Java集合 "

Hashmap的put()流程?

" "第一步:根据要添加的键值对的key的哈希码计算在数组中的位置(索引);

第二步:检查该位置是否为空;

  • 如果为空,则直接创建一个链表节点来存储键值对,并保存在该位置。
第三步:如果该位置已经存在其他键值对,则首先检查该位置的第一个节点的哈希码和键值是否与要添加的键值对相同?
         如果相同,则直接替换value值即可;
 
第四步:如果不相同,则需要遍历链表或红黑树来查找是否有相同的键:
  • 如果找到了相同的键,直接替换value值;
  • 如果没有找到相同的键,则创建新的节点添加到链表/红黑树中;添加完节点之后,对于链表,还需要检查链表长度是否达到阈值(默认为8),如果链表长度超过阈值,且数组长度大于等于64,则还需要将链表转换为红黑树;
 
第五步:添加完键值对之后,将HashMap的修改次数(modCount)加1,以便在进行迭代时发现并发修改。
(modCount:记录HashMap的修改次数,在迭代的时候,会将modCount赋值到迭代器的expectedModCount属性中,然后进行迭代, 如果在迭代的过程中HashMap被其他线程修改了,modCount的数值就会发生变化, 这个时候expectedModCount和ModCount不相等, 迭代器就会抛出异常)
 
第六步:最后令记录哈希表中节点数量size加一,并判断其是否超过了阈值threashold:
如果超过了则需要进行扩容操作:
  • 创建一个新的两倍大小的数组。
  • 将旧数组中的键值对分配到其哈希值映射在新数组的下标中;
至此完成put()操作。

" Java集合

 

 

 

三、Java并发

#separator:tab #html:true #tags column:3

并发&并行、同步&异步

①并发&并行

  • 并发:多个任务在同一 时间段 内执行。
  • 并行:多个任务在同一 时刻 执行。
 

②同步&异步

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回;

Java并发 "

volatile关键字是如何保证内存可见性的?底层是怎么实现的?

+

为什么需要保证内存可见性?

" "

volatile关键字是如何保证内存可见性的?底层是怎么实现的?

volatile关键字通过两种机制来保证内存可见性:
  • 内存屏障(Memory Barrier):在多核处理器架构下,每个线程都有自己的独立缓存,volatile关键字通过在写操作后插入写屏障(Write Barrier),强制将写入独立缓存的数据写入主存,通过在读操作前插入读屏障(Read Barrier),强制从主存中加载数据到独立缓存中,这样一来就可以确保一个线程中变量的更新能够立即被其他线程看到,保证内存可见性。
  • 禁止指令重排序:在程序运行时,为了提高性能,编译器可能会对指令进行重排序,这可能会导致变量的更新操作被延迟或打乱,使得其他线程无法立即看到最新的值。使用volatile关键字修饰的变量会禁止指令重排序,确保程序的执行顺序与代码编写顺序一致

为什么需要保证内存可见性?

如果不保证内存可见性,就可能会在并发环境下出现数据不一致的情况,例如一个线程修改了共享变量的值,但其他线程无法立即看到最新值,而是读取到了过期数据,从而产生错误的结果。

" Java并发 "

synchronized底层原理?

+

synchronized锁升级的过程?

+
为什么需要延迟初始化?
+
三种锁分别适用于什么场景?
+
偏向锁可以重入,轻量级锁也可以重入,那偏向锁还有什么存在的必要?

"

  • 底层实现:synchronized底层是使用monitor锁对象和四种锁状态的升级来实现的;(同一时刻至多只有一个线程能持有monitor对象,其他线程再想获取这个monitor时会被阻塞住)
 
  • 锁升级:根据Java对象头中的Mark Word字段,锁在JVM中有四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态;
    Ⅰ. 在JVM启动时会延时初始化偏向锁,默认时间为4s,在此期间创建的锁对象都处于无锁状态,如果有线程访问会直接升级为轻量锁;
    Ⅱ. 之后创建的锁对象则都为匿名偏向状态,当第一个线程获取该锁对象时,会进入偏向锁状态,并在对象头中存入该线程id,之后若同一线程再次访问临界区,就只需要比较线程id确定是同一个线程即可直接访问临界区;
    Ⅲ. 但当有其他线程试图获取该锁对象时,就会撤销偏向锁并升级为轻量级锁,此时线程需要通过CAS自旋操作的方式尝试将对象头中的锁信息替换为当前线程id,
      如果替换成功,表示获取了锁,可以进入临界区;
      如果替换失败并且自旋超过一定次数,则升级为重量级锁;
    Ⅳ. 重量级锁状态下,操作系统由用户态进入内核态,JVM会将等待获取锁的线程阻塞并放入阻塞队列,等待操作系统唤起;
 
 
为什么需要延迟初始化?
因为在JVM启动时会有大量同步的操作,并不适合偏向锁,如果使用偏向锁会导致频繁的锁撤销和锁升级操作,降低了JVM的启动效率;
 
三种锁分别适用于什么场景?
偏向锁适用于单个线程重入的场景
轻量级锁适用于少量线程的交替执行的场景
重量级锁适用于大量线程同步执行的场景;
 
偏向锁可以重入,轻量级锁也可以重入,那偏向锁还有什么存在的必要?
目前看来,偏向锁存在的价值是为历史遗留的Collection类如Vector和HashTable等做优化,迟早药丸,Java 15中已经默认不开启。

Java并发 "

ReentrantLock底层原理

+
ReentrantLock加锁流程(以非公平锁为例)
+
ReentrantLock公平锁和非公平锁的区别
+
ReentrantLock解锁流程

" "

ReentrantLock底层原理

ReentrantLock基于AQS(AbstractQueuedSynchronizer)实现,
AQS底层有一个volatile修饰的表示锁状态的变量state,state=0表示锁未被占用,state>0表示锁已被占用,和一个用来存放等待获取锁的线程的等待队列(底层是一个双向链表),以及一个记录当前持有独占锁的线程的变量;
 
ReentrantLock加锁流程(以非公平锁为例)
  首先通过CAS操作将state值由0设为1的方式来抢占锁,
  如果抢占成功,则直接当前线程设为持有独占锁的线程,开始访问临界区;
  如果没有抢到,则还会再尝试一次CAS抢锁,同时判断持有锁的线程是否是当前线程,如果是则执行可重入的逻辑,每重入一次就令state加一;
  如果还是没有抢到,也不是可重入的情况,则将当前线程包装成AQS的Node节点,以CAS操作的方式放入AQS等待队列的队尾,等待被唤醒;
 
 
ReentrantLock公平锁和非公平锁的区别:
  公平锁指的是各线程按照申请锁的顺序来获取锁,非公平锁则是随机。具体实践上,如果是公平锁,那么线程会先看等待队列中有无节点,如果有则先放入队尾排队,而非公平锁则会直接通过CAS操作试图获取锁,获得失败才会进入队尾;
 
ReentrantLock解锁流程:
  令state减去指定值,如果state变为0,则将AQS的持有锁线程设为null,表示当前锁没有被占用;

" Java并发

ReentrantLock和synchronized的异同

"

相同点:二者都是可重入且独占式的锁;
不同点:
  ①synchronized是系统自带的锁,自动加锁和解锁,而ReentrantLock是手动加锁和释放锁,更加灵活;
  ②synchronized只能是非公平锁,ReentrantLock既可以是公平锁,也可以是非公平锁;
  ③synchronized底层是monitor锁对象和四种锁状态的升级,而ReentrantLock是AQS通过CAS操作实现的;
  ④synchronized一旦开始申请锁,就只能等到获得锁之后才可以进行其他逻辑,而ReentrantLock的锁等待过程可以被其他线程中断,可以用来解除死锁;
(使用lockInterruptibly()获取锁,等待过程可以通过interrupt()方法中断)
  ⑤ReentrantLock锁还支持超时获取锁失败功能,synchronized不支持;(tryLock()方法可以获取锁超时的阈值)
 
 
使用层面的区别:
synchronized的锁范围分为给实例对象和给整个类加锁,
其中,修饰实例方法是给实例对象加锁,修饰静态方法是给整个类加锁,修饰代码块看括号内是实例对象还是.class;
并且synchronized的对象锁和类锁不互斥,可以同时访问;

" Java并发 "

什么是线程池?有哪些核心参数?

+

线程池处理任务的流程

+

为什么核心线程满了之后是先加入阻塞队列而不是直接加到总线程?

+

线程池大小如何确定?

+

如何动态设置线程池的参数

" "

什么是线程池?有哪些核心参数?

线程池就是管理一系列线程的资源池,使用线程池可以降低重复创建、销毁线程的资源消耗,同时提高线程的可管理性;
 

线程池核心参数主要有:

Ⅰ. corePoolSize:核心线程数,默认情况下核心线程即使空闲也不会回收,不过可以通过调用allowCoreThreadTimeOut(true)来让核心线程也可以被回收;
Ⅱ. maximumPoolSize:线程池中允许存在的最大线程数量;
Ⅲ. keepAliveTime:超过核心线程数量的空闲线程处于空闲多长时间会被回收,一般取默认值60s即可;
Ⅳ. unit:keepAliveTime的时间单位;
Ⅴ. workQueue:任务队列,用于存放等待执行的任务;
Ⅵ. handler:拒绝策略,当线程池达到最大线程且任务队列已满时,如何处理新的任务;
Ⅶ. threadFactory:线程工厂,用于创建新的线程,可以用来设定线程名;
 
 

线程池处理任务的流程:

Ⅰ. 如果当前线程数小于核心线程数,则立即创建一个核心线程来处理任务;
Ⅱ. 如果当前线程数大于核心线程数,且任务队列未满,则将任务放进任务队列中等待;
Ⅲ. 如果当前线程数大于核心线程数,且任务队列已满,但小于最大线程数,则创建一个非核心线程来处理任务;
Ⅳ. 如果当前线程数等于最大线程数,且任务队列已满,则执行拒绝策略;
 
 

为什么核心线程满了之后是先加入阻塞队列而不是直接加到总线程?

线程池创建线程需要获取mainlock这个全局锁,会影响并发效率,所以使用阻塞队列把第一步创建核心线程与第三步创建最大线程隔离开来,起一个缓冲的作用;
 

线程池大小如何确定?

一般来说,最佳线程数=CPU核数*(1 + (线程等待时间/线程计算时间)),
对于CPU密集型任务,就是1+N,对于IO密集型任务(磁盘IO、网络IO),就是2*N;
在实际应用中也可以考虑动态地对线程池大小进行修改;
 
 

如何动态设置线程池的参数?

Ⅰ. 修改核心线程数——setCorePoolSize()
使用ThreadPoolExecuter提供的setCorePoolSize()方法即可在运行时修改线程池的核心线程数;
线程池运行时corePoolSize发生改变时的处理策略:
  首先和当前工作线程数比较,若小于当前工作线程数,则对空闲线程进行回收;
  然后和原始值比较,若大于原始值,且任务队列中有任务,就创建新线程;
 
Ⅱ. 修改最大线程数——setMaximumPoolSize()
线程池运行时maximumPoolSize发生改变时的处理策略:
  若最大线程数小于当前工作线程数,则对工作线程进行回收,否则什么都不做;
 
Ⅲ. 修改任务队列容量
可以复制LinkedBlockingQueue的代码自定义一个阻塞队列,将capacity属性去掉final修饰,改为volatile,然后添加get()/set()方法,
然后用这个自定义阻塞队列作为线程池的任务队列,这样就可以在线程池运行时,使用setCapacity()方法修改任务队列容量了;
 
Ⅳ. 需要监控线程池的哪些参数?
线程活跃度=活跃线程数/最大线程数、任务队列中等待的任务数量、执行拒绝策略的次数等;

" Java并发

线程池的任务队列有哪些?如何选择?

+

常见的拒绝策略有哪些,如何选择?

+

线程池状态有哪些?

+

如何给线程池里的线程命名?

 

"

线程池的任务队列有哪些?如何选择?

任务队列主要分为三类:
Ⅰ. 无界队列:这种队列大小无限制,默认为Integer.MAX_VALUE,常见的有LinkedBlockingQueue,在任务耗时较长时可能会导致任务队列堆积大量任务,最终导致OOM;
 
Ⅱ. 有界队列:这种队列在创建时就会指定容量大小,常见的有FIFO的ArrayBlockingQueue、还有支持优先级排序的PriorityBlockingQueue;
 
Ⅲ. 同步移交队列:这种队列不存放任务,而是直接将任务移交给线程去执行,常见的有SynchronousQueue;
 

常见的拒绝策略有哪些,如何选择?

Ⅰ. AbortPolicy:直接抛出异常,终止任务,这也是默认的拒绝策略;
Ⅱ. CallerRunsPolicy:不丢弃任务,而是使用调用者线程本身执行任务,可以确保任务完成,不过由于调用者线程执行任务期间后续任务需要阻塞等待,当任务较多时效率较低;
Ⅲ. DiscardPolicy:直接丢弃任务,不做任何处理;
Ⅳ. DiscardOldestPolicy:丢失任务队列中最老的任务,再将新任务加入到任务队列中;
Ⅴ. 自定义拒绝策略:实现RejectedExecutionHandler接口和其中的rejectedExecution()方法即可,例如将拒绝的任务异步持久化到磁盘中,再通过一个后台线程去定时扫描这些被拒绝的任务,逐个执行;
 
 

线程池状态有哪些?

RUNNING:运行状态,可以接收任务;
SHUTDOWN:执行shutdown()方法后进入此状态,不再接收新任务,但会把正在执行和任务队列中的任务处理完;
STOP:执行shutdownNow()方法后进入的状态,不再接收新任务,同时中断正在处理的任务,任务队列中的任务也全部返回;
TIDYING:线程池自主整理状态,自行执行terminated()方法对线程池进行整理;
TERMINATED:线程池终止状态;
 

如何给线程池里的线程命名?

默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。
可以通过guava的ThreadFactoryBuilder生成ThreadFactory,再在创建线程池时传入参数即可给线程池里的线程命名;

" Java并发 "

创建线程池的方法new ThreadPoolExecuter(),为什么不推荐使用工具类Executers自带的线程池?

+
创建线程的方法有哪些?
+
预热线程池有什么方法?
 +
执行线程池有哪些方法,有什么区别?
+
关闭线程池有哪些方法,有什么区别?
+
awaitTermination()方法有什么用?

" "

创建线程池一般使用方法new ThreadPoolExecuter(),为什么不推荐使用工具类Executers自带的线程池?

Executers自带的线程池有:
FixedThreadPool、SingleThreadPool:前者允许创建固定数量的线程,后者只允许创建一个线程,这两个问题都在于任务队列为无界队列LinkedBlockingQueue,允许存放Integer.MAX_VALUE个任务,可能会因为任务队列堆积过量请求而导致OOM;
CachedThreadPool:任务队列为SynchronousQueue,不存放任务而是直接移交给线程去执行,允许创建Integer.MAX_VALUE个线程,可能会因为创建过量线程而导致OOM;
 
 
创建线程的方法有哪些?
①继承Thread类,重写run()方法;之后new对象调用start()方法即可运行;
②实现Runnable接口和run()方法:之后new对象调用start()方法即可运行;
③实现Callable接口和call()方法:call()方法有返回值,如果想要接收返回值,就创建一个FutureTask对象并new一个这个线程对象当参数传入,之后再以FutureTask对象为参数new一个Thread对象调用start()方法来运行,运行完通过FutureTask对象.get()来获得返回值;
④线程池创建:new ThreadPoolExecuter()创建线程池,然后线程池.execute()或submit()传入实现了Runnable或是Callable接口的对象来创建并执行线程;
 
 
预热线程池有什么方法?
刚创建的线程池中是没有线程的,为避免刚开始就传来大量任务,因为需要创建大量线程而导致响应速度慢,可以提前创建好线程,
prestartCoreThread()、prestartAllCoreThreads():提前创建一个、或全部核心线程;
 
 
执行线程池有哪些方法,有什么区别?
excute()方法只能传入Runnable,没有返回值,
submit()方法既能传入Callable,也能传入Runnable,传入Callable的话会返回一个Future对象,用于获取任务的执行结果;
 
 
关闭线程池有哪些方法,有什么区别?
shutdown():执行shutdown()方法后线程池进入SHUTDOWN状态,不再接收新任务,但会把正在执行和任务队列中的任务处理完;
shutdownNow():执行shutdownNow()方法后进入STOP状态,不再接收新任务,同时中断正在处理的任务,任务队列中的任务也全部返回;
 
 
awaitTermination()方法有什么用?
因为线程池无论是执行shutdown()还是shutdownNow()都是异步通知线程池关闭,线程池本身还需要经历TIDYING状态整理线程池,之后才会正式终止进入TERMINATED状态,
所以如果需要同步等待线程池关闭,之后才能进行后续操作,可以使用awaitTermination()方法同步等待线程池关闭,可以设定最长等待时间避免等待过长,还需要使用try-catch捕获异常;

" Java并发

如何控制线程的执行顺序?

"

①使用CountDownLatch倒计时器

初始化:new CountDownLatch(int count),这里count为几,就是让一个线程等待多少线程执行完之后才能执行;
在需要先执行的线程结尾调用countDown(),在后执行的线程开头调用await(),即可实现按序执行,
不过每需要实现一次先后顺序,就需要一个CountDownLatch,比如需要线程1->线程2->线程3,那就需要两个初始化为1的CountDownLatch;
 

②使用join()方法——需要显示地创建线程,才能使用线程调用join()方法,使用线程池时不能显示地创建线程,故不适用

thread.join():令执行此方法的线程阻塞,直至thread线程执行完毕;
因此如果需要执行顺序为thread1->thread2->thread3,
可以这样写:
        thread1.start();
        thread1.join();
        thread2.start();
        thread2.join();
        thread3.start();
也可以把thread1.join()、thread2.join()写在分别线程2、3的run()方法开头;
 

③使用wait()和notify()——因为是对对象调用,所以线程池中也能用

在需要先执行的线程结尾调用notify(),在需要后执行的线程开头调用wait()方法,使用逻辑和CountDownLatch差不多;
——注意对object调用wait()、notify()都需要在synchronized给对应的object加对象锁的情况下;(wait()/notify()底层应该也是通过监视器monitor实现的)

" Java并发

乐观锁与悲观锁

+
MySQL乐观锁实现

"

①悲观锁:

总是假设最坏的情况,每次拿数据的时候都认为别的线程会修改,所以每次在获取资源的时候都会上锁,这样别的线程要获取这个资源就需要先等待获得锁,
常见的ReentrantLock和synchronized都是悲观锁;
悲观锁的优点是数据一致性高;
悲观锁的缺点是每次获取资源前都要先获得锁,效率比较低,可能还会导致死锁或是线程堵塞;
适用场景:读少写多,竞争激烈;
 
 

②乐观锁:

总是假设最好的情况,每次拿数据的时候都认为别的线程不会修改,所以每次在获取资源的时候都不上锁,只会在提交修改的时候验证资源是否已被其他线程修改了;
常见的乐观锁可以通过CAS算法实现:核心思想就是比较线程携带的标记和访问对象中对应的标记,在两者相同时进行更新数据的操作,这两步是通过一个原子操作实现的,不会被其他线程打断;
乐观锁的优点是因为不用获取锁,效率较高;
乐观锁的缺点在于首先它只能保证单个变量的原子性,
  其次在多线程竞争激烈的情况下,乐观锁可能会一直自旋,无法对共享变量进行修改,性能反而不如悲观锁,
  最后乐观锁还存在ABA的问题,即访问对象中的标记可能经过多次修改又变回的原来的值,导致错误判断,不过这一点可以通过添加版本号或是时间戳来解决;
适用场景:读多写少,竞争较少;
 
 
MySQL乐观锁实现
给表添加version字段,当需要进行update操作时,
先查询该记录当前version:select * from t_goods where name = 'apple';
然后执行update操作时,添加条件version=刚才查出的版本号,
添加操作令version = version + 1:
update t_goods set price = 11, version = version + 1 where name = 'apple' and version = 1;
 
 

" Java并发

什么是ThreadLocal?

+

底层原理

+
既然可能产生内存泄漏,为什么要将ThreadLocalMap的key设计为弱引用?
+
那能不能将value设计为弱引用?

"

①什么是ThreadLocal?

ThreadLocal是一个线程私有变量,它可以让在多线程情况下让每个线程都独立地维护自己的变量副本,互不干扰;
其应用场景主要是一些需要每个线程存储各自信息的场景,例如用户身份信息传递、数据库连接管理、事务管理等;
 

②底层原理

每个线程内部都会维护一个ThreadLocalMap,其中存储着以ThreadLocal为key,Object为value的键值对,ThreadLocal的get()、set()操作本质上就是对线程内的这个哈希表的操作,这就保证了线程间的隔离性;
 
 
但由于ThreadLocalMap中使用的key是对ThreadLocal的弱引用,而value是对Object的强引用,因此在JVM进行垃圾回收的时候,可能会出现key被回收,而value没有被回收的情况,此时就会出现key为null的Entry,除非手动清理,否则value不会被垃圾回收,就会产生内存泄漏,
不过ThreadLocal在设计时就考虑到了这一点,当调用get()、set()、remove()方法时都会清除key为null的value值,实际使用时推荐使用完ThreadLocal之后调用remove()方法清理;
 
既然可能产生内存泄漏,为什么要将ThreadLocalMap的key设计为弱引用?
因为如果设计成强引用的话,即使线程结束销毁,其ThreadLocal中存储的Entry(包括key和value)仍不会被垃圾回收,会造成更严重的内存泄漏,
而将key设计为弱引用的话,就可以保证key会被回收掉,同时调用get()、set()、remove()方法时都会清除key为null的value值,最大程度上避免了内存泄漏;
 
那能不能将value设计为弱引用?
不能,因为弱引用只要JVM进行垃圾回收就会被回收掉,这就可能导致get()方法得到的是null值;

" Java并发

并发编程三个特性

 +

wait()和sleep()对比

并发编程三个特性

①可见性:一个线程对共享变量进行修改,另一个线程应当立即得到修改后的值;——可以通过volatile、各种锁来实现;
②原子性:一组操作在执行过程中,不会被其他线程中断;——可以通过加锁来实现,悲观锁、乐观锁都可以
③有序性:为了提高执行效率,程序在编译运行的时候可能会对指令进行重排,这种重排在单线程情况下不会改变执行结果,但在多线程情况下可能会出现问题;——通过用volatile修饰来禁止指令重排,就可以确保程序的执行顺序与代码编写顺序一致
 
 

wait()和sleep()对比

相同点:都可以暂停线程的执行进入等待状态,都需要使用try-catch捕获异常;
不同点:
  Ⅰ. wait()是Object类的本地方法,必须配合synchronized一起调用,单独使用会抛出异常,而sleep()是Thread类的静态本地方法,可以在任何地方调用;
  Ⅱ. wait()会释放当前线程占有的对象锁,需要重新获取锁才能唤醒,sleep()不会释放对象锁;
  Ⅲ. 使用wait()方法需要通过别的线程调用同一对象上的notify()或notifyAll()方法来唤醒,如果有多个线程等待在同一个对象上,调用notify()方法会随机选择一个线程唤醒,而notifyAll()方法会唤醒所有等待线程。被唤醒的线程会竞争对象锁,只有获得锁的线程才会进入运行状态,或是通过wait(long timeout)带参数等时间到了苏醒,
   而sleep()方法只能等待时间到了才会苏醒;
  Ⅳ. wait()方法通常用于线程间通信,例如用来控制线程执行顺序,而sleep()通常用于暂停线程执行;

Java并发

死锁的四个必要条件

+

死锁的处理策略——预防死锁

+

死锁的处理策略——避免死锁

+

死锁的处理策略——死锁的检测和解除

①死锁的四个必要条件

  互斥:资源同一时刻只允许一个线程占用;
  不可剥夺:线程在获取资源之后,除非主动释放,其他线程无法强行剥夺;
  请求与保持:线程在因请求资源而阻塞时,会继续保持已有的资源不释放;
  循环等待:多个线程之间的请求资源形成了循环;
 
 

②死锁的处理策略——预防死锁

  破坏互斥条件:将互斥资源改造为共享资源;
  破坏不可剥夺条件:允许优先级高的线程剥夺优先级低的线程占用的资源;
  破坏请求保持条件:一次性请求所有的资源,待获取全部所需资源之后再开始运行;
  破坏循环等待条件:可以使用资源有序分配法,按照顺序申请资源,反序释放资源;——最实用
 
 

死锁的处理策略——避免死锁

使用银行家算法,在进行资源分配时按照全部进程都能获取资源的安全序列进行分配;
 
 

④死锁的处理策略——死锁的检测和解除

使用资源分配图检测是否发生了死锁,若发生死锁,
可以通过下列三种方法解除死锁:
Ⅰ. 资源剥夺法:将某些死锁进程挂起,抢占其资源并分给其它进程;
Ⅱ. 撤销进程法:强制撤销部分死锁进程,并剥夺其资源;
Ⅲ. 进程回退法:让一个或多个进程回退到足以避免死锁的阶段,这种方法要求系统记录进程的历史信息,设置还原点;

Java并发 "

进程和线程的区别

+

线程的生命周期/状态

+

什么时候会发生线程上下文切换?

" "

进程和线程的区别

进程是运行起来的可执行程序,是资源分配的单位,进程切换开销很大,包括虚拟地址空间和内核堆栈等的切换,
一个进程可以有多个线程,线程是程序执行和调度的基本单位,线程切换的开销很小,只需要切换程序计数器和栈空间(包括虚拟机栈和本地方法栈)即可,
——协程是用户态的轻量级线程,是线程内部调度的基本单位,特点是同一时间只有一个协程运行无法并行,且切换时无需进入内核态,因此切换速度非常快;
 
为什么程序计数器线程私有?
每个线程有各自的执行进度,为了线程切换后能恢复到正确的执行位置;
 
为什么虚拟机栈和本地方法栈私有?
为了保证每个线程中的局部变量不被别的线程访问到;
 

线程的生命周期/状态

Java线程在运行的生命周期中有6种状态:(比操作系统中的线程状态少了一个READY就绪状态,多了等待、超时等待两个状态)
  NEW初始状态:线程被创建之后,被调用start()之前;
  RUNNABLE可运行状态:线程被调用start()等待运行的状态,和正在运行的状态;
  BLOACKED阻塞状态:等待锁释放;
  WAITITNG等待状态:例如线程调用了wait()方法,需等待其它线程调用notify()或是notifyAll()方可返回RUNNABLE状态,例如通知或中断;
  TIME_WAITING超时等待状态:例如线程调用了sleep()方法,需要等待指定时间,超过时间后自动返回;
  TERMINATED终止状态:线程运行完毕;
 

什么时候会发生线程上下文切换?

线程在下列几种情况下会发生上下文切换:
  通过调用sleep()、wait()方法等主动让出CPU;——RUNNABLE变为TIME-WAITING或是WAITING
  时间片用完;——都属于RUNABLE
  yield()方法——线程进入就绪态
  调用了阻塞类型的系统中断,例如请求IO和线程被阻塞;——RUNABLE变为BLOCKED

" Java并发

 

 

 

四、JVM

#separator:tab #html:true #tags column:3 JVM内存区域
+
介绍一下栈在函数运行时的作用(以c=a+b为例) JVM内存区域

JVM内存结构分为5个区域:程序计数器、虚拟机栈、本地方法栈、堆内存、方法区;
Ⅰ. 程序计数器:记录当前线程所指向的字节码(.class文件)的行号,对代码进行流程控制;
Ⅱ. 虚拟机栈:虚拟机栈由多个栈帧组成,栈帧中通过局部变量表、操作数栈等结构存放了执行当前方法所需的各种数据,包括入参、局部变量、返回值等;
(每个方法对应一个栈帧,每次方法调用时都会有一个栈帧入栈,方法调用结束就会有一个栈帧弹出)
Ⅲ. 本地方法栈:本地方法栈就是虚拟机栈的本地方法版本,用于执行Native本地方法;
Ⅳ. 堆内存:堆用于存储对象实例,可以细分为老年代、新生代(Eden、From Survivor、To Survivor),是垃圾回收的主要区域;
Ⅴ. 方法区:用于存储加载的类信息、常量、静态变量、编译后的代码等数据;
 
 
 
介绍一下栈在函数运行时的作用(以c=a+b为例)
 
 

JVM 堆内存组成
+
什么是大对象?
+
HotSpot JVM中GC的种类
+
堆中死亡对象判断方法有哪些?/如何判断哪些对象需要被回收?
+
引用类型有哪些?
+
常见的垃圾回收算法有哪些?
+
常见的垃圾回收器
+
GC调优的技巧&注意事项 堆内存组成
堆内存分为新生代(包括Eden、S0、S1)、老年代(Tenured)、永久代(PermGen,JAVA8之后被元空间Metaspace取代,元空间使用的是直接内存);
新生代用于存放新创建的对象,老年代用于存放长时间存活的对象,而永久代/元空间用于存放类信息、方法信息等元数据;



什么是大对象?
大对象指占用的内存空间达到一定阈值的对象,例如超过堆内存的一半,大对象的类型可以是数组、集合、字符串等;

大对象可能会带来一些问题:
①大对象需要大量连续的内存空间来存储,如果堆内存中没有足够的连续空间,就会触发垃圾回收或导致内存分配失败;
②大对象的存在会影响垃圾回收器的行为,例如在进行Full GC时需要考虑大对象的回收成本,可能会导致长时间的停顿;
③大对象的内存分配和释放可能会产生内存碎片;

通常解决办法有:
①可以通过对象池或是缓存来复用大对象,以减少内存分配和垃圾回收的开销;
②可以使用G1垃圾回收器:在G1中将一个Region(1MB到32MB之间)装不下的对象称为Humougous对象,对于这种大对象:
       Ⅰ. 首先G1会直接将其分配到老年代当中,避免了大对象的复制和移动操作;
       Ⅱ. 其次,大对象在老年代中会被单独管理,不会和其它对象混合放在同一个Region中,避免产生大量内存碎片;
       Ⅲ. 最后,在垃圾回收时,G1会优先回收大对象,以减少内存碎片;



HotSpot JVM中GC的种类
总的来说有两大类:
①部分收集Partial GC,也包括:
         新生代收集Minor GC:只对新生代进行垃圾收集;
         老年代收集Major GC:只对老年代进行垃圾收集;
         混合收集Mixed GC:对整个新生代和部分老年代进行垃圾收集;
②整堆收集Full GC:收集整个堆和方法区;



堆中死亡对象判断方法有哪些?/如何判断哪些对象需要被回收?
①引用计数法:每当有一个地方引用它,计数器就加一,反之当引用失效就减一,计数器为0的对象就是需要被回收的对象;
——这个方法很少用,主要它很难解决循环引用的问题;
②可达性分析算法:以一系列被称为GC Roots的对象作为起点开始向下搜索,如果一个对象无法被搜索到,就说明它是不可用的需要被回收;
——可以作为GC Roots的对象:虚拟机栈/本地方法栈中引用的对象、方法区常量、静态属性引用的对象等;



引用类型有哪些?

强引用:只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收;
软引用:在一次垃圾回收后,若内存仍不足时会再次触发垃圾回收,回收软引用对象;
弱引用:在垃圾回收时,无论内存是否充足,都会回收弱引用对象;
虚引用:可以在任何时候被垃圾回收;




常见的垃圾回收算法有哪些?

Ⅰ. 标记清除算法
利用可达性算法去遍历内存,对存活对象和垃圾对象进行标记,标记结束后统一将所有垃圾对象回收掉,
这种算法会产生大量不连续的内存碎片;
 
Ⅱ. 标记整理算法
标记过程和标记清除法一样,但后续步骤是让所有存活的对象都向一端移动,之后直接清理掉边界外的内存;
这种算法不会产生内存碎片;
 
Ⅲ. 复制算法
将内存分为大小相同的两块,每次使用其中的一块,用完之后就将存活的对象复制到另一块,然后将之前的那块全部清理掉;
这种算法效率较高,但会浪费一半的内存空间;
 
Ⅳ. 分代收集算法
根据处于新生代还是老年代选用不同的垃圾回收算法:
对于新生代,每次垃圾收集存活对象只占很小一部分,复制成本较低,所以用复制算法,
对于老年代,对象存活率高,所以使用标记清除或是标记整理算法;




常见的垃圾回收器

Ⅰ. Serial收集器:单线程的垃圾回收器,特点是实现简单,但在进行垃圾回收时需要暂停所有其他线程,适用于小型应用程序;
Ⅱ. Parellel收集器:多线程的垃圾回收器,特点是可以自适应调节参数来让系统可以获得最大吞吐量,适用于对吞吐量要求较高的中型应用程序;
Ⅲ. CMS收集器:多线程的垃圾回收器,特点是以获取最短回收停顿时间为目标,适用于对响应时间有要求的中型应用程序;
Ⅳ. G1收集器:一款面向服务器的垃圾回收器,适用于有多个处理器和大容量内存的机器,回收停顿时间很短,且具有高吞吐量;(JDK9之后的默认垃圾回收器)




GC调优的技巧&注意事项
①根据应用程序的性能需求选择合适的垃圾收集器,例如Serial单线程收集器适合小型应用程序,Parallel收集器适合对吞吐量要求较高的中型应用程序,CMS收集器适合对响应时间要求较高的中型应用程序,G1收集器适合大型应用程序(内存8GB以上);

②可以通过调整JVM堆的参数来调整JVM的垃圾回收频率以及停顿时间,
例如初始堆大小(-Xms)、最大堆大小(-Xmx)、新生代大小(-Xmn)等,调整时为了避免内存剧烈波动,应当多次小范围调整,直至GC频率、停顿时间处于合理的区间(GC频率ops为0.5,也即每秒0.5次,2秒一次左右比较合适,停顿时间在80ms以下比较合理);

③还可以设置垃圾收集器的参数来调优,例如设置新生代和老年代的比例、垃圾收集的触发条件、垃圾收集的线程数量等,如果是G1垃圾收集器还能设置期望的最大垃圾收集停顿时间;

④使用JConsole等监控工具对JVM的性能进行实时监控和分析,及时发现和解决性能瓶颈;

⑤通过优化Java代码和算法来减少对象的创建和销毁、减少内存占用、降低垃圾收集的频率,从而提高程序的性能和效率;








堆内存分配原则
①大部分情况下,对象在新生代Eden区分配,当Eden区空间不足时,就会发起一次Minor GC;
②对于一些需要大量连续内存空间的大对象,比如字符串和数组,虚拟机会让其直接进入老年代;(这个阈值有些垃圾回收器例如G1可以通过参数进行设置,有些则是由JVM自行根据堆内存情况决定)
③新生代中存活时间达到一定阈值的对象也会进入老年代;
(默认是15岁,也就是经过15次Minor GC,也可以通过垃圾回收器的参数进行调整)
JVM 类加载过程/new一个对象的过程?
+
类加载器是什么?
+
什么是双亲委派模型? "类加载过程/new一个对象的过程?

Ⅰ. 加载:首先JVM会检查该类是否已被加载,若没有则通过类加载器进行加载,将类的字节码加载到内存中;
Ⅱ. 连接:这部分又分为验证、准备、解析三步,
  验证:JVM对类的字节码进行验证,确保符合Java语言规范;
  准备:JVM为类的静态变量分配内存,并赋初始值为默认值;
  解析:JVM将常量池中的符号引用替换为直接引用;(符号引用包括类和接口的全限定名、字段和方法的名称、描述符)
Ⅲ. 初始化:如果父类还没初始化就先初始化父类,然后执行类的初始化代码,包括静态变量的赋值、静态方法的调用、以及静态代码块的执行;
Ⅳ. 创建对象:为对象分配内存空间、设置对象头信息和执行构造方法;
 
 
类加载器是什么?
类加载器ClassLoader是一个负责加载类的对象,每个 Java 类都有一个引用指向加载它的ClassLoader,数组类除外,数组类由JVM直接生成;
常见的类加载器有BootStrapClassLoader启动类加载器、ExtensionClassLoader扩展类加载器、ApplicationClassLoader应用程序类加载器,还可以通过继承ClassLoader来自定义类加载器;
(除了BootStrapClassLoader其他类加载器均由 Java 实现且全部继承自ClassLoader
ClassLoader类有两个关键方法:
loadClass():通过双亲委派机制来加载指定名称的类;
findClass():无法被父加载器加载的类最终会通过这个方法被加载;
如果我们不想打破双亲委派模型,就重写  findClass() 方法即可,如果想打破双亲委派模型则需要重写 loadClass() 方法;
 
 
什么是双亲委派模型?
双亲委派模型是指每个类加载器在试图加载类时,会先检查这个类是否已被加载过,如果已加载就直接返回,未被加载则调用父加载器的loadClass()方法,每个层次的加载器都是这样,因此所有加载请求都会先委托到最顶层的启动类加载器,
启动类加载器如果加载成功就直接返回,如果加载失败则抛出异常,此时子加载器会调用自己的findClass()方法进行加载;
(父加载器、子加载器不是以继承的方式实现的,而是通过组合的方式实现的)
 
双亲委派模型的优点在于可以避免类的重复加载,保证Java的核心Api不被篡改,因为JVM区分不同类的方式除了看全类名是否相同,还要看加载这个类的加载器是否一样。
(例如用户自己写了一个java.lang.Object,此时启动类加载的还会是Java自带的Object类)

" JVM G1(Garbage First)垃圾收集器特点
+
GC过程:
+
G1和CMS的区别 "G1(Garbage First)垃圾收集器特点:
G1将堆划分为多个大小相等的区域Region,这些Region可以是新生代空间,也可以是老年代空间,然后根据每个Region回收获得的空间大小、回收所需时间,在后台维护一个优先级列表,每次进行GC时,会在允许的收集停顿时间内回收那些优先级最高的区域,而不需要在整个新生代/老年代、甚至整个堆中进行垃圾回收了,从而实现可控的GC停顿时间;



G1的GC过程:
①初始标记阶段:首先标记出GC Roots能直接关联到的对象,这个阶段会短暂地暂停应用程序的执行;
②并发标记阶段:在应用程序正常运行的情况下,并发地标记出所有可达的对象;
③最终标记阶段:这个阶段也会让应用程序短暂暂停,处理并发标记阶段开始后新的引用关系的变化;
④筛选回收阶段:更新优先级列表中各个区域的回收价值与成本,并进行排序,然后根据用户期望的收集停顿时间选择优先级最高的那些Region进行回收,回收时采用复制算法,将其中的存活对象复制到空Region之后再清理旧Region;(在此阶段中用户程序也需要暂停)



CMS的GC过程:
①前三个阶段和G1是一样的;
②最后一个阶段CMS是并发清除:使用标记清除算法删除掉那些被标记的垃圾对象,由于不需要移动存活对象,因此这个阶段和用户线程并发运行;



G1和CMS的区别
①从使用的垃圾回收算法来看:G1在整体上使用标记-整理算法,从局部(两个Region)来看使用复制算法,这两种算法都不会产生内存碎片,但由于需要移动存活对象,因此回收阶段需要暂停用户线程;
而CMS则使用了标记-清除算法,这种算法会产生内存碎片,但由于不需要移动存活对象,因此回收阶段可以和用户线程并发运行;
②从停顿时间上来看:G1将堆内存分为一个个大小相等的Region,垃圾回收时也是以Region为单位,在允许的收集停顿时间内优先回收那些优先级最高的Region,正因此G1可以通过设置目标停顿时间参数来控制垃圾回收的停顿时间,
而CMS则没有Region这个概念,垃圾回收必须对整个新生代/老年代/整个堆进行,无法保证稳定的低停顿时间,可能会出现长时间的Full GC暂停;" JVM

 

posted @   Avava_Ava  阅读(18)  评论(0编辑  收藏  举报
(评论功能已被禁用)
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
点击右上角即可分享
微信分享提示