java基础面试题总结
文章目录
- java基础面试题总结
- 一、JAVA
- 1、什么是面向对象?谈谈你对面向对象的理解
- 1.1、什么是面向对象?
- 1.2、面向对象三大特征
- 1、封装
- 2、继承:
- 3、多态
- 2、JDK JRE JVM三者的区别与联系
- 2.1、JDK:
- 2.2、JRE:
- 2.3、JVM:
- 2.4、JDK文件目录
- 2.5、区别与联系
- 3、==和equals比较
- 4、hashCode与equals
- 4.1、equals介绍
- 4.2、hashCode介绍:
- 4.3、hashCode() 函数。
- 4.4、为什么要有hashCode:
- 4.5、总结
- 5、final
- 5.1、简述final的作用。
- final:最终的
- 修饰成员变量
- 修饰局部变量
- 修饰基本类型数据和引用类型数据
- 5.2、为什么局部内部类和匿名内部类只能访问局部final变量?
- 6、String、StringBuffer、StringBuilder
- 7、重载和重写的区别
- 7.1、重载
- 7.2、重写
- 8、接口和抽象类的区别
- 9、List和Set的区别
- 10、ArrayList和LinkedList区别
- 11、HashMap和HashTable有什么区别?其底层实现是什么?
- 11.1、区别 :
- 11.2、底层实现:数组+链表实现
- 12、ConcurrentHashMap原理,jdk7和jdk8版本的区别
- 12.1、dk7:
- 12.2、jdk8:
- 12.3、扩展
- 1、HashTable和ConcurrentHashMap区别
- 13、Java中的异常体系
- 14、什么是字节码?采用字节码的好处是什么?
- 14.1、java中的编译器和解释器
- 14.2、采用字节码的好处
- 15、Java类加载器有哪些
- 15.1、BootStrapClassLoader
- 15.2、ExtClassLoader
- 15.3、AppClassLoader
- 15.4、自定义加载器
- 1、什么时候需要自定义类加载器?
- 2、如何自定义类加载器?
- 15.5、扩展
- 1、线程上下文加载器
- 16、双亲委托机制
- 16.1、步骤:
- 16.2、双亲委派模型的好处:
- 16.3、 沙箱安全机制
- 17、GC如何判断对象可以被回收
- 17.1、垃圾回收相关算法
- 17.2、GC Roots的对象有:
- 17.3、可达性分析算法详解
- 二、线程、并发相关
- 1、线程的生命周期?线程有几种状态
- 2、sleep()、wait()、join()、yield()的区别
- 2.1、锁池
- 2.2、等待池
- 2.3、sleep、wait的区别
- 2.4、yield()
- 2.5、join()
- 3、对线程安全的理解
- 4、Thread、Runable的区别
- 5、对守护线程的理解
- 5.1、理解
- 5.2、守护线程的作用是什么?
- 5.3、 应用场景
- 5.4、注意事项
- 6、ThreadLocal的原理和使用场景
- 6.1、原理
- 6.2、使用场景:
- 6.3、案例
- 6.4、ThreadLocal图解
- 7、ThreadLocal内存泄露原因,如何避免
- 7.1、内存泄漏概念
- 7.2、jvm虚拟机的引用
- 7.3、hreadLocalMap使用ThreadLocal的弱引用作为key的内存泄漏
- 7.4、hreadLocalMap使用ThreadLocal的强引用作为key的内存泄漏
- 7.5、为什么要用弱引用
- 7.6、 ThreadLocal正确的使用方法
- 8、并发、并行、串行的区别
- 9、并发的三大特性
- 9.1、原子性
- 9.2、可见性
- 9.3、有序性
- 9.4、synchronized、volatile的使用
- 10、volatile
- 10.1、作用
- 10.2、总结
- 11、为什么用线程池?解释下线程池参数?
- 11.1、作用
- 11.2、参数
- 12、简述线程池的工作流程
- 13、线程池中阻塞队列的作用?为什么是先添加列队而不是先创建最大线程?
- 13.1、阻塞队列的作用
- 13.2、为什么是先添加列队而不是先创建最大线程
- 14、线程池中线程复用原理
一、JAVA
1、什么是面向对象?谈谈你对面向对象的理解
1.1、什么是面向对象?
- 对比面向过程,是两种不同的处理问题的角度
- 面向过程更注重事情的每一个步骤及顺序
- 面向对象更注重事情有哪些参与者(对象、及各自需要做什么)
- 比如:做一件事,洗衣机洗衣服
- 面向过程会将任务拆解成一系列的步骤(函数),1、打开洗衣机----->2、放衣服----->3、放洗衣粉----->4、清洗----->5、烘干
- 面向对象会拆出人和洗衣机两个对象:
- 人:打开洗衣机 放衣服 放洗衣粉
- 洗衣机:清洗 烘干
- 从以上例子能看出,面向过程比较直接高效,而面向对象更易于复用、扩展和维护
1.2、面向对象三大特征
1、封装
- 封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项内部细节对外部调用透明,外部调用无需修改或者关心内部实现。
- 封装场景:
- 1、javabean的属性私有,提供getset对外访问,因为属性的赋值或者获取逻辑只能由javabean本身决定。而不能由外部胡乱修改
private String name; public void setName(String name){ //必须以 tuling为前缀,如果以类.name的方式赋值,可能违背了JavaBean的命名规则 this.name = "tuling_"+name; } 该name有自己的命名规则,明显不能由外部直接赋值
- 2、orm框架
操作数据库,我们不需要关心链接是如何建立的、sql是如何执行的,只需要引入mybatis,调方法即可。细节都进行了封装,只需要如何使用就行(封装思想)。
- 1、javabean的属性私有,提供getset对外访问,因为属性的赋值或者获取逻辑只能由javabean本身决定。而不能由外部胡乱修改
2、继承:
- 继承:继承基类(父类)的方法,并做出自己的改变和/或扩展
- 子类共性的方法或者属性直接使用父类的,而不需要自己再定义(代码复用),子类只需扩展自己个性化的
3、多态
- 多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。(使用举例子的方式能够更清楚直白)
父类类型 变量名 = new 子类对象 ; 变量名.方法名(); //调用的是子类重写之后的方法
- 前提条件:继承、方法重写、父类引用指向子类对象
- 如:Person类、Teacher类、Student类,Teacher类、Student类都继承了 Person类 并重写了,Person类中的 work()方法,并做出了不同的实现逻辑,如果需要更改逻辑,只需指向不同的子类对象即可,无需更改调用代码。
//指向父类引用指向子类引用,Person 指向 Teahcer类 Person p = new Teacher(); p.work(); // 调用的是Teacher类中重写的逻辑 p = new Student(); p.work();// 调用的是Student类中重写的逻辑
- 如:Person类、Teacher类、Student类,Teacher类、Student类都继承了 Person类 并重写了,Person类中的 work()方法,并做出了不同的实现逻辑,如果需要更改逻辑,只需指向不同的子类对象即可,无需更改调用代码。
- 优点:更容易程序的维护与扩展。
- 缺点:父类无法调用子类特有的功能且父类中必须有的方法并进行重写才能使用多态。(可通过向下转型的方式解决,也就是将父类强转成子类对象)。
2、JDK JRE JVM三者的区别与联系
2.1、JDK:
Java Develpment Kit java 开发工具(开发程序员需要安装),Java标准开发包,它提供了编译、运行Java程序所需的各种工具和资源,包括Java编译器、Java运行时环境,以及常用的Java类库等。
2.2、JRE:
Java Runtime Environment Java运行环境,用于解释执行Java的字节码文件。(用户执只需要运行程序,只要安装这个即可)
2.3、JVM:
java Virtual Machine java 虚拟机(解释编译java代码,转成class文件),Java虚拟机,是JRE的一部分。它是整个java实现跨平台的最核心的部分,负责解释执行字节码文件,是可运行java字节码文件的虚拟计算机。所有平台的上的JVM向编译器提供相同的接口,而编译器只需要面向虚拟机,生成虚拟机能识别的代码,然后由虚拟机来解释执行。
2.4、JDK文件目录
2.5、区别与联系
- JDK 用于开发,JRE 用于运行java程序 ;如果只是运行Java程序,可以只安装JRE,无序安装JDK。
- JDk包含JRE,JDK 和 JRE 中都包含 JVM。
- JVM 是 java 编程语言的核心并且具有平台独立性。
- 图解
3、==和equals比较
-
== 对比的是栈中的值,基本数据类型是变量值,引用类型是堆中内存对象的地址。
-
equals:object类中默认也是采用==比较,通常会重写(为了比较成员变量中的值)。不作处理,跟 == 没什么差别。
-
Object类中equals()的定义。
public boolean equals(Object obj) { return (this == obj); }
-
String类重写equals()方法。
public boolean equals(Object anObject) { //this表示:调用改方法的对象 anObject表示:对比的数据 if (this == anObject) { return true; } //判断是否是 Stirng类型 if (anObject instanceof String) { //强转 String anotherString = (String)anObject; //获取查长度 int n = value.length; //两个字符串长度是否相等 if (n == anotherString.value.length) { //将当前字符串的字符数组赋值给临时变量 char v1[] = value; //将对比的数据的字符数组赋值给临时变量 char v2[] = anotherString.value; //坐标 int i = 0; //循环判断 while (n-- != 0) { //如果有一个字符不想等,就返回false,证明两个字符串不相等 if (v1[i] != v2[i]) return false; //坐标累加 i++; } //跳出while循环,证明每个每个下标对应的字符相等,就返回true return true; } } return false; }
上述代码可以看出,String类中被复写的equals()方法其实是比较两个字符串的内容。
-
== 必须是同一个房子,equal房子长得一样就行。
4、hashCode与equals
4.1、equals介绍
- equals它的作用也是判断两个对象是否相等,如果对象重写了equals()方法,比较两个对象的内容是否相等;如果没有重写,比较两个对象的地址是否相同,价于“==”。equals()定义在JDK的Object.java中,这就意味着Java中的任何类都包含有equals()函数。
4.2、hashCode介绍:
- hashCode() 的作用是获取哈希码,也称为散列码;它实际上是返回一个int整数。 - 这个哈希码的作用是
- 确定该对象在哈希表中的索引位置。hashCode() 定义在JDK的Object.java中,Java中的任何类都包含有
4.3、hashCode() 函数。
- 散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)
4.4、为什么要有hashCode:
- 以“HashSet(底层用的是HashMap)如何检查重复”为例子来说明为什么要有hashCode:
- 对象加入HashSet时,HashSet会先计算对象的hashcode值来判断对象加入到hash表的位置,看该位置是否有值,如果没有、HashSet会假设对象没有重复出现,直接放入该位置上。但是如果发现当前位置有值(hash冲突),这时会调用equals()方法来检查两个对象是否真的相同。如果两者相同,HashSet就不会让其加入操作成功。如果不同的话,直接将该元素放入到集合中。这样就大大减少了equals的次数(如果没有hashCode,hash表中的每个对象都需要与插入进来的equals()一下),相应就大大提高了执行速度。
- 流程图
4.5、总结
- 如果两个对象相等,则hashcode一定也是相同的(这里的相等,是指equals()方法相等),两个对象相等,对两个对象分别调用equals()方法都返回true
- 两个对象有相同的hashcode值,它们的equals()方法也不一定是相等的,因此,equals()方法被覆盖(重写)过,则hashCode方法也必须被覆盖(重写)
- hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)
5、final
5.1、简述final的作用。
final:最终的
- 修饰类:表示类不可被继承
- 修饰方法:表示方法不可被子类覆盖,但是可以重载
- 修饰变量:表示变量一旦被赋值就不可以更改它的值。
修饰成员变量
- 如果final修饰的是类变量(静态变量),只能在静态初始化块中指定初始值或者声明该类变量时指定初始值。
- 如果final修饰的是成员变量,可以在非静态初始化块、声明该变量或者构造器中执行初始值。
修饰局部变量
- 系统不会为局部变量进行初始化,局部变量必须由程序员显示初始化。因此使用final修饰局部变量时,即可以在定义时指定默认值(后面的代码不能对变量再赋值),也可以不指定默认值,而在后面的代码中对final变量赋初值(仅一次)
public class FinalVar { //再声明的时候就需要赋值 或者静态代码块赋值 final static int a = 0; /** //静态代码块赋值 static{ a = 0; } */ //再声明的时候就需要赋值 或者代码块中赋值 或者构造器赋值 final int b = 0; /*{ b = 0; }*/ public static void main(String[] args) { //局部变量只声明没有初始化,不会报错,与final无关。 final int localA; //在使用之前一定要赋值 localA = 0; //但是不允许第二次赋值 会报错 //localA = 1; } }
修饰基本类型数据和引用类型数据
- 如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;
- 如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象。但是引用的值是可变的。
public class FinalReferenceTest { public static void main() { final int i; //合法 i = 1; //非法 报错 i = 1; final int[] iArr = {1, 2, 3, 4}; iArr[2] = -3;//合法 //非法,对iArr不能重新赋值 报错 iArr = null; final Person p = new Person(25); //合法 p.setAge(24); //非法 报错 p = null; } }
5.2、为什么局部内部类和匿名内部类只能访问局部final变量?
- 示例代码:编译之后会生成两个class文件,Test.class Test1.class
public class Test { public static void main(String[] args) { } //局部final变量a,b public void test(final int b) {//jdk8在这里做了优化, 不用写final,语法糖,但实际上也是有的,也不能修改 final int a = 10; //匿名内部类 new Thread(){ public void run() { //a、b不定义成final 会报错 System.out.println(a); System.out.println(b); }; }.start(); } } class OutClass { private int age = 12; public void outPrint(final int x) { //有名内部类 class InClass { public void InPrint() { //局部内部类使用局部变量,必须为final,访问全局变量不需要 System.out.println(x); System.out.println(age); } } new InClass().InPrint(); } }
- 首先需要知道的一点是: 内部类和外部类是处于同一个级别的,内部类不会因为定义在方法中就会随着方法的执行完毕就被销毁。
- 这里就会产生问题:当外部类的方法结束时,局部变量就会被销毁了,但是内部类对象可能还存在(只有没有人再引用它时,才会死亡)。这里就出现了一个矛盾:内部类对象访问了一个不存在的变量。为了解决这个问题,就将局部变量复制了一份作为内部类的成员变量,这样当局部变量死亡后,内部类仍可以访问它,实际访问的是局部变量的"copy"。这样就好像延长了局部变量的生命周期
- 将局部变量复制为内部类的成员变量时,必须保证这两个变量是一样的,也就是如果我们在内部类中修改了成员变量,方法中的局部变量也得跟着改变,怎么解决问题呢?
- 就将局部变量设置为final,对它初始化后,我就不让你再去修改这个变量,就保证了内部类的成员变量和方法的局部变量的一致性。这实际上也是一种妥协。使得局部变量与内部类内建立的拷贝保持一致。
6、String、StringBuffer、StringBuilder
- String是final修饰的,不可变,每次操作都会产生新的String对象
- StringBuffer和StringBuilder都是在原对象上操作
- StringBuffer是线程安全的,StringBuilder线程不安全的
- StringBuffer方法都是synchronized修饰的
- 性能:StringBuilder > StringBuffer > String
- 场景:经常需要改变字符串内容时使用后面两个,不经常改动的字符串,使用Stirng即可
- 总结:在不考虑多线程的情况下,优先使用StringBuilder,多线程使用共享变量时使用StringBuffer
7、重载和重写的区别
7.1、重载
- 概念:发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同,也可以相同,发生在编译时。
- 注意:下列代表不属于重载,返回值不同不属于重载且相同顺序的参数类型,参数名称不一样也不属于重载。
public int add(int a,String b)
public String add(int a,String b)
//编译报错
7.2、重写
- 概念:发生在父子类中,方法名、参数列表必须相同,返回值范围小于等于父类(如果父类的方法是基本数据类型或void,子类是需要一致的),抛出的异常范围小于等于父类,访问修饰符范围大于等于父类;如果父类方法访问修饰符为private则子类就不能重写该方法。
8、接口和抽象类的区别
- 抽象类可以存在普通成员函数,而接口中只能存在public abstract 方法。
- 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是public static final类型的(默认会自行添加)。
- 抽象类只能继承一个,接口可以实现多个。
- 接口的设计目的,是对类的行为进行约束(更准确的说是一种“有”约束,因为接口不能规定类不可以有什么行为),也就是提供一种机制,可以强制要求不同的类具有相同的行为。它只约束了行为的有无,但不对如何实现行为进行限制。
- 而抽象类的设计目的,是代码复用。当不同的类具有某些相同的行为(记为行为集合A),且其中一部分行为的实现方式一致时(A的非真子集,记为B),可以让这些类都派生于一个抽象类(先有子类再由父类)。在这个抽象类中实现了B,避免让所有的子类来实现B,这就达到了代码复用的目的。而A减B的部分,留给各个子类自己实现。正是因为A-B在这里没有实现,所以抽象类不允许实例化出来(否则当调用到A-B时,无法执行)。
- 抽象类是对类本质的抽象,表达的是 is a 的关系,比如: BWM is a Car 。抽象类包含并实现子类的通用特性,将子类存在差异化的特性进行抽象,交由子类去实现。
- 而接口是对行为的抽象,表达的是 like a 的关系。比如: Bird like a Aircraft (像飞行器一样可以飞),但其本质上 is a Bird 。接口的核心是定义行为,即实现类可以做什么,至于实现类主体是谁、是如何实现的,接口并不关心。
- 使用场景:当你关注一个事物的本质的时候,用抽象类;当你关注一个操作的时候,用接口。
- 抽象类的功能要远超过接口,但是,定义抽象类的代价高。因为高级语言来说(从实际设计上来说也是)每个类只能继承一个类。在这个类中,你必须继承或编写出其所有子类的所有共性。虽然接口在功能上会弱化许多,但是它只是针对一个动作的描述。而且你可以在一个类中同时实现多个接口。在设计阶段会降低难度。
9、List和Set的区别
- List:有序,按对象进入的顺序保存对象,可重复,允许多个Null元素对象,可以使用Iterator取出所有元素,在逐一遍历,还可以使用get(int index)获取指定下标的元素
- Set:无序,不可重复,最多允许有一个Null元素对象,取元素时只能用Iterator接口取得所有元素,在逐一遍历各个元素
10、ArrayList和LinkedList区别
- ArrayList:基于动态数组,连续内存存储,适合下标访问(随机访问),不适合插入及删除(需要整体移动数组中的部分元素,耗内存和时间)
- ArrayList存在的问题:
- 扩容机制:因为数组长度固定,超出长度存数据时需要新建数组,然后将老数组的数据拷贝到新数组(有ArrayList构造器能够指定数组的长度)
- 不指定ArrayList的初始容量,在第一次add的时候会把容量初始化为10个,这个数值是确定的;
- ArrayList的扩容时机为add的时候容量不足(比如初始为10,当添加第11个元素的时候),扩容的后的大小为原来的1.5倍(如 ArrayList的容量为10,一次扩容后是容量为16),扩容需要拷贝以前数组的所有元素到新数组;
- 如果不是尾部插入数据(最末端插入元素)还会涉及到元素的移动(需要将后面的数据往后复制一份,插入新元素)
- 扩容机制:因为数组长度固定,超出长度存数据时需要新建数组,然后将老数组的数据拷贝到新数组(有ArrayList构造器能够指定数组的长度)
- ArrayList存在的问题解决办法:
- 使用尾插法并指定初始容量可以极大提升性能、甚至超过linkedList(linkedList需要创建大量的node对象)
- LinkedList:基于链表,可以存储在分散的内存中,适合做数据插入及删除操作,不适合查询(需要循环遍历链表中的每个元素,找到对应的元素)
- LinkedList存在的问题:
- 需要逐一遍历:遍历LinkedList必须使用iterator不能使用for循环,因为每次for循环体内通过get(i)取得某一元素时都需要对list重新进行遍历,性能消耗极大。
- 另外不要试图使用indexOf等返回元素索引,并利用其进行遍历,使用indexlOf对list进行了遍历,当结果为空时也会遍历整个列表。
11、HashMap和HashTable有什么区别?其底层实现是什么?
11.1、区别 :
- HashMap方法没有synchronized修饰,线程非安全,HashTable线程安全(效率低);
- HashMap允许key和value为null,而HashTable不允许;
11.2、底层实现:数组+链表实现
- jdk7都数组+链表,元素内部类为Entry节点存在。
- jdk8开始链表高度到8、数组长度超过64,链表转变为红黑树(弱平衡二叉树),元素以内部类Node(k-v形式)节点存在。
- 存储集合元素的过程:
- 计算key的hash值,二次hash,然后对数组长度取模,对应到数组下标,
- 如果没有产生hash冲突(下标位置没有元素),则直接创建Node存入数组,
- 如果产生hash冲突,先进行equal比较,相同则取代该元素,不同,则判断链表高度插入链表,链表高度达到8,并且数组长度到64,则转变为红黑树,长度低于6则将红黑树转回链表
- key为null,存在下标0的位置
- 数组扩容机制:
- 容量默认是16,加载因子默认是0.75,阈值=容量*加载因子,默认是12,当元素数量超过阈值时便会触发扩容,扩容为原来的2倍。
- 流程图
12、ConcurrentHashMap原理,jdk7和jdk8版本的区别
12.1、dk7:
- 数据结构:ReentrantLock+Segment(段)+HashEntry,一个Segment中包含一个HashEntry数组,每个HashEntry又是一个链表结构
- 元素查询:二次hash,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部
- 锁:Segment分段锁, Segment继承了ReentrantLock,锁定操作的Segment,其他的Segment不受影响,并发度为segment个数,可以通过构造函数指定,数组扩容不会影响其他的segment
- get方法无需加锁,volatile保证数据一致性(volatile是一个类型修饰符,修饰变量)。
12.2、jdk8:
- 数据结构:synchronized(在1.8中做了优化)+CAS(乐观锁,保证原子性)+Node+红黑树,Node的val和next都用volatile修饰,保证可见性
- 查找,替换,赋值操作都使用CAS
- 锁:锁链表的head节点,不影响其他元素的读写,锁粒度更细,效率更高,扩容时,阻塞所有的读写操作、并发扩容
- 读操作无锁:
- Node的val和next使用volatile修饰,读写线程对该变量互相可见
- 数组用volatile修饰,保证扩容时被读线程感知
12.3、扩展
1、HashTable和ConcurrentHashMap区别
- HashTable:底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
- ConcurrentHashMap:底层采用分段的数组+链表实现,线程安全
- ConcurrentHashMap是使用了锁分段技术来保证线程安全的。锁分段技术:首先将数据分成一段(Segment)一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
- ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。HashTable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。
- ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的,其关键在于使用了锁分离技术。
13、Java中的异常体系
- Java中的所有异常都来自顶级父类Throwable。
- Throwable下有两个子类Exception和Error。
- Error是程序无法处理的错误(如:OOM内存溢出),一旦出现这个错误,则程序将被迫停止运行(整个程序的所有线程都将停止)。
- Exception不会导致程序停止,又分为两个部分RunTimeException运行时异常和CheckedException检查异常(在编译时期就能够检查出来)。
- RunTimeException常常发生在程序运行过程中,会导致程序当前线程执行失败,不会导致其他线程失败。(自定义异常一般都继承这个这个RunTimeException)
- CheckedException常常发生在程序编译过程中,会导致程序编译不通过。
- 体系图
14、什么是字节码?采用字节码的好处是什么?
14.1、java中的编译器和解释器
- Java中引入了虚拟机的概念,即在机器(系统)和编译程序(java源代码)之间加入了一层抽象的虚拟的机器。这台虚拟的机器在任何平台上都提供给编译程序一个的共同的接口。
- 编译程序只需要面向虚拟机,生成虚拟机能够理解的代码,然后由解释器来将虚拟机代码转换为特定系统的机器码执行。在Java中,这种供虚拟机理解的代码叫做 字节码(即扩展名为 .class的文件),它不面向任何特定的处理器,只面向虚拟机。
- 每一种平台的解释器是不同的,但是实现的虚拟机是相同的。Java源程序经过编译器编译后变成字节码,字节码由虚拟机解释执行,虚拟机将每一条要执行的字节码送给解释器,解释器将其翻译成特定机器上的机器码,然后在特定的机器上运行。这也就是解释了Java的编译与解释并存的特点。
- Java源代码---->编译器---->jvm可执行的Java字节码(即虚拟指令)---->jvm---->jvm中解释器----->机器可执行的二进制机器码---->程序运行。
14.2、采用字节码的好处
- Java语言通过字节码的方式,在一定程度上解决了传统解释型语言(解释一句执行一句)执行效率低的问题(java是在编写完后就已经编译好了,无需运行时进行编译),同时又保留了解释型语言可移植的特点。所以Java程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,Java程序无须重新编译便可在多种不同的计算机上运行。
15、Java类加载器有哪些
详情可见这篇文章
- JDK自带有三个类加载器:BootStrapClassLoader(启动类加载器)、ExtClassLoader(扩展类加载器)、AppClassLoader(系统类加载器)。
15.1、BootStrapClassLoader
- BootStrapClassLoader是ExtClassLoader的父类加载器,默认负责加载%JAVA_HOME%lib下的jar包和class文件(Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。
15.2、ExtClassLoader
- ExtClassLoader是AppClassLoader的父类加载器,负责加载%JAVA_HOME%/lib/ext文件夹下的jar包和class类(如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载)。
15.3、AppClassLoader
- AppClassLoader是自定义类加载器的父类,负责加载classpath下的类文件。系统类加载器,线程上下文加载器。
15.4、自定义加载器
1、什么时候需要自定义类加载器?
- 隔离加载类(比如说我假设现在Spring框架,和RocketMQ有包名路径完全一样的类,类名也一样,这个时候类就冲突了。不过一般的主流框架和中间件都会自定义类加载器,实现不同的框架,中间价之间是隔离的)修改类加载的方式
- 扩展加载源(还可以考虑从数据库中加载类,路由器等等不同的地方)
- 防止源码泄漏(对字节码文件进行解密,自己用的时候通过自定义类加载器来对其进行解密)
2、如何自定义类加载器?
- 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
- 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findclass()方法中
- 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URIClassLoader类,这样就可以避免自己去编写findclass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
15.5、扩展
1、线程上下文加载器
- 类加载的“全盘负责”
- 所谓类加载器的“全盘负责”机制:例如当一个类加载器负责加载某个Class时,该Class所依赖的引用的其他Class也将由该类加载器尝试负责加载,除非显示指定另外一个类加载来加载。如:ClassX引用了ClassY,那么加载ClassX的加载器会去尝试加载ClassY(前提是ClassY尚未加载)
- 为什么需要线程上下文加载器
- Java提供了很多服务者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。
这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由启动类加载器(Bootstrap Classloader)来加载的;SPI的实现类是由系统类加载器(AppClassLoade)来加载的。引导类加载器是无法找到 SPI 的实现类的(由于类加载的“全盘负责”机制,当某个接口中),因为依照双亲委派模型,BootstrapClassloader无法委派AppClassLoader来加载类,从这里看出,SPI核心接口由启动类加载器来加载,SPI具体实现类由系统类加载器来加载。如:加载jdbc.jar 用于实现数据库连接的时候,由 SPI 的实现所提供的,第三方的jar包中的类属于系统类加载器来加载。
- 而线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。
- Java提供了很多服务者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。
16、双亲委托机制
- 双亲委派:向上查找缓存(每个类加载器中都有自己的缓存),向下查找加载路径。
16.1、步骤:
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
- 父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类,如果将加载任务分配至系统类加载器也无法加载此类,则抛出异常
16.2、双亲委派模型的好处:
- 主要是为了安全性,避免用户自己编写的类动态替换 Java的一些核心类,比如 String(案例详情链接)。
- 同时也避免了类的重复加载,因为 JVM中区分不同类,不仅仅是根据类名,相同的 class文件被不同的 ClassLoader加载就是不同的两个类
- 在JVM中表示两个class对象是否为同一个类存在两个必要条件:
- 类的完整类名必须一致,包括包名
- 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同
16.3、 沙箱安全机制
- 自定义String类时:在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java.lang.String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。
- 这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
17、GC如何判断对象可以被回收
GC详情笔记
17.1、垃圾回收相关算法
- 引用计数法:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收;引用计数法存在的问题,可能会出现A 引用了 B,B 又引用了 A(循环引用),这时候就算他们都不再使用了,但因为相互引用 计数器=1 永远无法被回收。
- 可达性分析法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,那么虚拟机就判断是可回收对象。
17.2、GC Roots的对象有:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法)引用的对象
17.3、可达性分析算法详解
- 可达性算法中的不可达对象并不是立即死亡的,对象拥有一次自我拯救的机会。对象被系统宣告死亡至少要经历两次标记过程:第一次是经过可达性分析发现没有与GC Roots相连接的引用链,第二次是在由虚拟机自动建立的Finalizer队列中判断是否需要执行finalize()方法。
- 当对象变成(GC Roots)不可达时,GC会判断该对象是否执行了finalize方法,若执行了,则直接将其回收。否则,若对象未执行过finalize方法,将其放入F-Queue队列,由低优先级线程执行该队列中对象的finalize方法。执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”
- 每个对象只能触发一次finalize()方法
- 由于finalize()方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,不推荐大家使用,建议遗忘它。
二、线程、并发相关
1、线程的生命周期?线程有几种状态
- 线程通常有五种状态,创建,就绪,运行、阻塞和死亡状态。
- 阻塞的情况又分为三种:
- (1)、等待阻塞:运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify或notifyAll方法才能被唤醒,wait是object类的方法
- (2)、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中。
- (3)、其他阻塞:运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep状态超时、join等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。sleep是Thread类的方法。
- 状态详情:
- 1.新建状态(New):新创建了一个线程对象。
- 2.就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
- 3.运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
- 4.阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。
- 5.死亡状态(Dead):线程执行完了或者因异常退出了run方法,该线程结束生命周期。
2、sleep()、wait()、join()、yield()的区别
2.1、锁池
- 所有需要竞争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配。
2.2、等待池
- 当我们调用wait()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了notify()或notifyAll()后等待池的线程才会开始去竞争锁,notify()是随机从等待池选出一个线程放到锁池,而notifyAll()是将等待池的所有线程放到锁池当中。
2.3、sleep、wait的区别
- sleep 是 Thread 类的静态本地方法,wait 则是 Object 类的本地方法。
- sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。
- sleep就是把cpu的执行资格和执行权释放出去,不再运行此线程,当定时时间结束再取回cpu资源,参与cpu的调度,获取到cpu资源后就可以继续运行了。而如果sleep时该线程有锁,那么sleep不会释放这个锁,而是把锁带着进入了冻结状态,也就是说其他需要这个锁的线程根本不可能获取到这个锁。也就是说无法执行程序。如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程也会抛出interruptexception异常返回,这点和wait是一样的。
- sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。
- sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。
- sleep 一般用于当前线程休眠,或者轮循暂停操作,wait 则多用于多线程之间的通信。
- sleep 会让出 CPU 执行时间且强制上下文切换,而 wait 则不一定,wait 后可能还是有机会重新竞争到锁继续执行的。
2.4、yield()
- yield()执行后线程直接进入就绪状态,马上释放了cpu的执行权,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行
2.5、join()
- join()执行后线程进入阻塞状态,例如在线程B中调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程
//main线程必须等t1线程执行完成之后在执行 public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("22222222"); } }); t1.start(); t1.join(); // 这行代码必须要等t1全部执行完毕,才会执行 System.out.println("1111"); } 22222222 1111
3、对线程安全的理解
- 对象池安全的理解不是不是线程安全,应该是内存安全,堆是共享内存,可以被所有线程访问
- 当多个线程访问一个对象(存储在堆中的)时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果(预期结果或者单线程实现的结果),我们就说这个对象是线程安全的
- 堆是进程和线程共有的空间,分全局堆和局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了要还给操作系统,要不然就是内存泄漏。
- 在Java中,堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
- 在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。
- 栈是每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语言里面显式的分配和释放。
- 目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。
4、Thread、Runable的区别
- Thread和Runnable的实质是继承关系(Thread实现了Runnable),没有可比性。无论使用Runnable还是Thread,都会new Thread,然后执行run方法。用法上,如果有复杂的线程操作需求,那就选择继承Thread,如果只是简单的执行一个任务,那就实现runnable。
- 会多出一个5-0的买票原因是:MyThread创建了两个实例,自然不会共享一个ticket,两个实例都会有属于自己的ticket,用法错误。
//会多一个5-0的票 public class Test { public static void main(String[] args) { new MyThread().start(); new MyThread().start(); } static class MyThread extends Thread { private int ticket = 5; public void run() { while (true) { System.out.println("Thread ticket = " + ticket--); if (ticket < 0) { break; } } } } }
- 正常情况,因为只有一个实例,共有一个ticket。但可能出现线程抢占出现问题售票问题,出现卖出-1张票的情况。
//正常卖出 public class Test2 { public static void main(String[] args) { MyThread2 mt = new MyThread2(); new Thread(mt).start(); new Thread(mt).start(); } static class MyThread2 implements Runnable { private int ticket = 5; public void run() { while (true) { System.out.println("Runnable ticket = " + ticket--); if (ticket < 0) { break; } } } } }
5、对守护线程的理解
5.1、理解
- 守护线程:为所有非守护线程提供服务的线程;任何一个守护线程都是整个JVM中所有非守护线程的保姆(一个守护线程服务所有非守护线程);
- thread.setDaemon(true) 将线程设置为守护线程
- 守护线程类似于整个进程的一个默默无闻的小喽喽;它的生死无关重要,它却依赖整个进程而运行;哪天其他线程结束了,没有要执行的了,程序就结束了,理都没理守护线程,就把它中断了;
- 注意: 由于守护线程的终止是自身无法控制的,因此千万不要把IO、File等重要操作逻辑分配给它;因为它不靠谱;
5.2、守护线程的作用是什么?
- 举例, GC垃圾回收线程:就是一个经典的守护线程,当我们的程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
5.3、 应用场景
- 1、来为其它线程提供服务支持的情况;
- 2、 或者在任何情况下,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用;反之,如果一个正在执行某个操作的线程必须要正确地关闭掉否则就会出现不好的后果的话,那么这个线程就不能是守护线程,而是用户线程。通常都是些关键的事务,比方说,数据库录入或者更新,这些操作都是不能中断的。
5.4、注意事项
- thread.setDaemon(true)必须在thread.start()之前设置,否则会抛出一个
IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。 - 在Daemon线程中产生的新线程也是Daemon的(在守护线程中产生的新线程也是守护线程)。
- 守护线程不能用于去访问固有资源,比如读写操作或者计算逻辑。因为它会在任何时候甚至在一个操作的中间发生中断。
- Java自带的多线程框架,比如ExecutorService,会将守护线程转换为用户线程,所以如果要使用后台线程就不能用Java的线程池。
6、ThreadLocal的原理和使用场景
ThreadLocal使用方法
6.1、原理
-
每一个 Thread 对象均含有一个 ThreadLocalMap 类型的成员变量 threadLocals ,它存储本线程中所有ThreadLocal对象及其对应的值
-
ThreadLocalMap 由一个个 Entry 对象构成
-
Entry 继承自 WeakReference<ThreadLocal<?>> ,一个 Entry 由 ThreadLocal 对象和 Object 构成。由此可见, Entry 的key是ThreadLocal对象,并且是一个弱引用。当没指向key的强引用后,该key就会被垃圾收集器回收
-
当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,将值存储进ThreadLocalMap对象中。
-
get方法执行过程类似。ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,获取对应的value。
-
由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。
6.2、使用场景:
- 1、在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。
- 2、线程间数据的隔离
- 3、进行事务操作,用于存储线程事务信息。
- 4、数据库连接,Session会话管理。
6.3、案例
- Spring框架在事务开始时会给当前线程绑定一个Jdbc Connection,在整个事务过程都是使用该线程绑定的connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来实现这种隔离
6.4、ThreadLocal图解
7、ThreadLocal内存泄露原因,如何避免
7.1、内存泄漏概念
- 内存泄露为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光,不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。
7.2、jvm虚拟机的引用
具体各个引用的介绍
- 强引用:使用最普遍的引用(new),一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。
- Spring框架在事务开始时会给当前线程绑定一个Jdbc Connection,在整个事务过程都是使用该线程绑定的connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来实现这种隔离
- 如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。
- 弱引用:JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。可以在缓存中使用弱引用。
- ThreadLocal的实现原理,每一个Thread维护一个ThreadLocalMap,key为使用弱引用的ThreadLocal实例,value为线程变量的副本
7.3、hreadLocalMap使用ThreadLocal的弱引用作为key的内存泄漏
- hreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时,Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉,但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链(红色链条)
7.4、hreadLocalMap使用ThreadLocal的强引用作为key的内存泄漏
- key 使用强引用当hreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
7.5、为什么要用弱引用
- 内存泄漏的情况中,都有两个前提:
- 没有手动删除这个Entry
- CurrentThread依然运行
- 无论ThreadLocalMap中的key使用哪种类型引用都无法完全避免内存泄漏,跟使用弱引用没有关系。只要记得在使用完ThreadLocal及时的调用remove,无论key是强引用还是弱引用都不会有问题。
- ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏。
- 事实上,在ThreadLocalMap中的set/getEntry方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为null的话,那么是会对value置为null的。
- 这就意味着使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障:弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。
7.6、 ThreadLocal正确的使用方法
- 每次使用完ThreadLocal都调用它的remove()方法清除数据
- 将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。
8、并发、并行、串行的区别
- 串行在时间上不可能发生重叠,前一个任务没搞定,下一个任务就只能等着
- 并行在时间上是重叠的,两个任务在同一时刻互不干扰的同时执行。
- 并发允许两个任务彼此干扰。统一时间点、只有一个任务运行,任务交替执行
9、并发的三大特性
9.1、原子性
- 原子性是指在一个操作中cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行。就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成。
- count++的执行步骤
private long count = 0; public void calc() { count++; }
- 1:将 count 从主存(堆)读到工作内存中的副本(操作数栈)中
- 2:+1的运算
- 3:将结果写入工作内存
- 4:将工作内存的值刷回主存(什么时候刷入由操作系统决定,不确定的)
- 程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。
- 使用关键字:synchronized,可以解决。
9.2、可见性
- 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。
//线程1 boolean stop = false; while (!stop) { doSomething(); } //线程2 stop = true;
- 如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
- 使用关键字:volatile、synchronized、final,可以解决
9.3、有序性
-
虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行(指令重排),有可能将他们重排序。实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题。
int a = 0; bool flag = false; public void write () { a = 2; //1 flag = true; //2 } public void multiply () { if (flag) { //3 int ret = a * a;//4 } }
-
单线程下,重排序不会导致结果出错。
-
多线程情况下, write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,再到线程1,这时候a才赋值为2,很明显迟了一步
-
使用关键字:volatile、synchronized可解决
- volatile本身就包含了禁止指令重排序的语义,而synchronized关键字是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则明确的。
9.4、synchronized、volatile的使用
- synchronized关键字同时满足以上三种特性,但是volatile关键字不满足原子性。
- 在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或
java.util.concurrent包里面的锁),因为volatile的总开销要比锁低。 - 我们判断使用volatile还是加锁的唯一依据就是volatile的语义能否满足使用的场景(原子性)
10、volatile
10.1、作用
-
保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
//线程1 boolean stop = false; while (!stop) { doSomething(); } //线程2 stop = true;
- 如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
-
禁止指令重排序优化。
int a = 0; boolean flag = false; public void write () { a = 2; //1 flag = true; //2 } public void multiply () { if (flag) { //3 int ret = a * a;//4 } }
- write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,再到线程1,这时候a才赋值为2,很明显迟了一步。
- 但是用volatile修饰之后就变得不一样了
10.2、总结
- 使用volatile关键字会强制将修改的值立即写入主存;
- 使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
- 由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
- inc++; 其实是两个步骤,先加加,然后再赋值。不是原子性操作,所以volatile不能保证线程安全。
11、为什么用线程池?解释下线程池参数?
11.1、作用
- 降低资源消耗;提高线程利用率,降低创建和销毁线程的消耗。
- 提高响应速度;任务来了,直接有线程可用可执行,而不是先创建线程,再执行。
- 提高线程的可管理性;线程是稀缺资源,使用线程池可以统一分配调优监控。
11.2、参数
- 构造器
- corePoolSize 代表核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会消除,而是一种常驻线程
- maxinumPoolSize 代表的是最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但是线程池内线程总数不会超过最大线程数
- keepAliveTime 、 unit 表示超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过setKeepAliveTime 来设置空闲时间
- workQueue 用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程
- ThreadFactory 实际上是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂
- Handler 任务拒绝策略,有两种情况,第一种是当我们调用 shutdown 等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提交的任务时,这是也就拒绝
12、简述线程池的工作流程
- 首先线程池接收到任务,先判断核心线程数是否满了,没有满接客,满了就放到阻塞队列,如果阻塞队列没满,这些任务放在阻塞队列,如果满了,就扩容线程数到最大线程数,如果最大线程数也满了,就是我们的拒绝策略。这就是线程池四大步骤。 接客、放入队列,扩容线程,拒绝策略!
13、线程池中阻塞队列的作用?为什么是先添加列队而不是先创建最大线程?
13.1、阻塞队列的作用
- 一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务。
- 阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。
- 阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用cpu资源
13.2、为什么是先添加列队而不是先创建最大线程
- 在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率。就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task > core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)。
14、线程池中线程复用原理
- 线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。
- 在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新启线程,而是让每个线程去执行一个“循环任务(相当于循环队列)”,在这个“循环任务”中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的 run 方法串联起来。
更多推荐
java基础知识系列面试题总结
发布评论