Java开发面试高频考点学习笔记(每日更新)
- 1.深拷贝和浅拷贝
- 2.接口和抽象类的区别
- 3.java的内存是怎么分配的
- 4.java中的泛型是什么?类型擦除是什么?
- 5.Java中的反射是什么
- 6.序列化与反序列化
- 7.Object有哪些方法?
- 8.JVM内存模型
- 9.类加载机制
- 10.对象的创建和对象的布局
- 11.Java的四种引用(强引用、软引用、弱引用和虚引用)
- 12.内存泄露和内存溢出
- 13.List、Set和Map三者的区别和其底层数据结构
- 14.创建线程的四种方式
- 15.NIO、AIO和BIO
- 16.重写和重载
- 17.final/finally/finalize与static
- 18.String、StringBuffer和StringBuilder的区别
- 19.如果判断一个对象是否该被回收?
- 20.垃圾收集算法
- 21.Double与Float
- 22.垃圾收集器
- 23.线程池
- 24.线程同步和线程通讯
- 25.中断线程
- 26.Synchronized的用法
- 27.Synchronized的原理
- 28.Synchronized的四种状态
- 29.Synchronized与重入锁ReentrantLock的区别
- 30.锁优化
- 31.Java设计模式
Java:
1.深拷贝和浅拷贝
内存中有栈区和堆区,基本类型数据直接存在栈中,而引用类型(new出来的)是在堆中存储,在栈中保存堆中的地址。也就是说引用类型中在栈中存的不是数据,而是地址。赋值其实就是拷贝。
在基本类型数据赋值的时候,没有深浅拷贝的区别,因为直接赋予的是数据。
但在引用类型数据赋值的时候,实际上是把原来的地址复制给了新的,并没有实际复制其中的数据,所以这是一个浅拷贝(拷贝的深度不够),当使用新的变量操作地址中的值的时候,旧变量对应的值也会发生改变。Java中Object
的clone
方法默认是浅拷贝。
深拷贝会创造另外一个一模一样的对象,新对象和原来的对象不共享内存,修改新对象不会影响旧对象。
参考文章
2.接口和抽象类的区别
抽象类:被
abstract
关键字修饰。抽象方法也被abstract
修饰,只有方法声明,没有方法体。抽象类不能被实例化,只能被继承
抽象类可以有属性、方法和构造方法,但是构造方法不能用于实例化,主要用于被子类调用
子类继承抽象类,必须实现抽象类抽象方法,否则子类必须也是抽象类
抽象类中的抽象方法只能是
public
或protected
接口:被
interface
关键字修饰。接口可以包含变量和方法;变量隐式设定为
public static final
,方法被隐式设定为public abstract接口支持多继承,一个接口可以
extends
多个接口一个类可以实现多个接口
jdk1.8中增加了默认方法和静态方法:
default/static
接口只能是功能的定义,而抽象类既可以为功能的定义也可以为功能的实现。
接口和抽象类都不能被实例化,接口的实现类和抽象类的子类只有实现了接口中/抽象类中的方法才能实例化。
实现接口的关键字是
implements
,继承抽象类的关键字是extends
。一个类可以实现多个接口,但一个类只能继承一个抽象类。接口强调特定功能的实现,而抽象类强调所属关系。
参考文章
3.java的内存是怎么分配的
内存分配分为在栈上分配和在堆上分配,大多数都是引用类型,所以堆空间用的较多。
对象根据存活时间分为年轻代、年老代、永久代(方法区)
年轻代:对象被创建时,首先分配在年轻代。年轻代有三个区域:Eden区,survivor 0
区和survive 1
区,Eden区大多数对象消亡速度很快,Eden是连续的内存空间,分配内存很快。Eden
区满的时候执行Minor GC
,清理消亡对象,将存活的对象放在survivor 0
区中,每次执行Minor GC
的时候,将剩余存活对象都放在非空的survivor
区中,survivor区满之后,就会清理并转移到另一个survivor
区,也就是说总有一个survivor
区是空的。HotSpot
虚拟机中默认切换15次之后,仍然存活的对象放在年老代中。
年老代:年老代的空间一般比年轻代大,存放更多的对象,年老代内存不足的时候,执行Major GC(Full GC)
,如果对象比较大的情况,可能直接放在老年代上。有可能出现老年代引用新生代对象的情况,java维护一个512 byte
的块“card table”
,记录引用映射,进行Minor GC
的时候直接查card table
就可以了。
参考文章
4.java中的泛型是什么?类型擦除是什么?
java源代码要运行,首先要经过编译器编译出字节码,字节码存储着能被JVM解释运行的指令。java的泛型在运行时,无法获得类型参数的真正类型,因为编译器编译生成的字节码不包括类型参数的具体类型。
泛型是java 1.5之后引入的,其本质是参数化类型,也就是说变量的类型是一个参数,在使用的时候再指定为具体类型,泛型可以用于类、接口和方法。
public class User {
private T name;
}//泛型实际上就是把类型当作参数传入了
而类型擦除机制使得Java的泛型实际上是伪泛型,类型参数只存在于编译期,运行时,JVM并不知道泛型的存在。
public class ErasedTypeEquivalence {
public static void main(String[] args) {
Class c1 = new ArrayList().getClass();
Class c2 = new ArrayList().getClass();
System.out.println(c1 == c2); //代码输出是true
}
}
在C++、C#这些支持真泛型的语言中,它们代表着不同的类,但在JVM看来他们是同一个类。无论何时定义一个泛型,相应的原始类型都会被自动提供,类型变量擦除,并使用其限定类型(无限定的变量用 Object)替换。Java 编译器是通过先检查代码中泛型的类型,然后在进行类型擦除,再进行编译。当具体的类型确定后,泛型提供了一种类型检测的机制,只有相匹配的数据才能正常的赋值,否则编译器就不通过。
参考文章
5.Java中的反射是什么
java反射就是把类中的各个成分映射成一个个java对象,在运行期间,对于任意一个类,都能够知道这个类的属性和方法,是一种动态获取信息、动态调用对象的方法。
- 优点:动态加载类,提高代码灵活度
- 缺点:降低性能,可能引起安全问题
我们使用的Spring/hibernate
中使用了反射机制,在使用JDBC连接数据库使用class.forName()
通过反射加载数据库的驱动程序。
Spring框架的IOC(动态加载管理bean)创建对象,AOP(动态代理)都和反射有关系。
6.序列化与反序列化
- 序列化:将Java对象转换成字节序列的过程。
- 反序列化:将字节序列转换成java对象。
serializable
接口是可以进行序列化的标志性接口,仅仅是告诉JVM该类对象可以进行序列化。
先让需要序列化的类实现serializable
接口;序列化对象创建输出流ObjectOutputStream
,然后调用writeObject()
方法;反序列化对象创建输入流Obje ctInputStream
,然后调用readObject()
方法,得到一个object对象。最后关闭流。
7.Object有哪些方法?
equals
:比较对象是否相等,这里实质是比较地址是否相等。
wait
:调用wait方法会导致线程阻塞,释放该对象的锁
notify
:调用对象的notify方法会随机解除该对象阻塞的线程,该线程重新获取该对象的锁
notifyAll
:唤醒所有正在等待对象的线程,全部进入锁池竞争获取锁
wait,notify,notifyAll
必须在synchronized方法块中使用。
toString
:转换为字符串表示
getClass
:返回对象运行时类,即反射机制。
hashCode
: 对象在内存中的地址转换为int值。
8.JVM内存模型
程序计数器(PC register):线程执行的字节码行号指示器,线程私有,唯一一个没有内存超出错误的区域。
- Java虚拟机栈:每个线程创建时都会创建一个虚拟机栈,内部保存一个个栈帧,对应每一次方法调用。生命周期与线程相同。保存方法的局部变量和部分结果,参与方法的调用和返回。如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflow
异常;如果虚拟机栈可以动态扩展,当扩展到无法申请足够内存时抛出OutOfMemoryError
异常。 - 本地方法栈:与虚拟机栈类似,但只为
native
方法服务。 - Java堆:线程共享内存,用来存放对象实例,是垃圾回收的主要区域。java堆可以处于物理上不连续的内存空间中,只要逻辑上连续就可以了,就类似于磁盘空间。如果在堆中没有内存完成实例分配,而且堆也无法再拓展的时候,将会抛出
OutOfMemoryError
的异常。 - 方法区:是线程共享内存,它用于存储已被虚拟机加载的类信息等数据。它可以叫做永久代也可以是元空间,在jdk1.8之后,永久代的数据被分配到堆和元空间中,元空间存储类信息,字符串常量和运行时常量池放入堆中。方法区无法满足内存分配需求时,抛出
OutOfMemoryError
异常。
JVM调优参数
(1) -Xms:初始化堆内存。默认为物理内存的六十四分之一
(2) -Xmx: 最大堆内存。默认为物理内存的四分之一
(3) -Xss:单个线程栈的大小
(4) -Xmn:设置新生代的大小
(5) -XX:MetaspaceSize
:设置元空间大小
(6) -XX:SurvivorRatio
:调节新生代eden和S0、S1的空间比例 默认为8:1:1
JVM性能监控工具
(1)jps -l
:查看进程号
(2)jstack
:java堆栈跟踪工具 查看死锁和cpu占用过高的代码
(3)jinfo -flag
查看运行的java程序参数属性的详情
9.类加载机制
类加载就是将类的数据从class文件加载到内存,并且进行校验解析和初始化,形成可以让虚拟机使用的java类型。
类的生命周期:加载,链接,初始化,使用,卸载。
- 加载:通过类名获取二进制字节流(通过类加载器),把静态数据结构放在方法区,内存中生成对应class对象,作为访问入口。
- 链接:确保当前字节流包含的信息符合虚拟机要求。正式分配内存,设置初始值(仅分配静态变量),虚拟机将常量池内的符号引用替换成直接引用。
- 初始化:按照代码逻辑,赋予属性真正的初始值,初始化阶段就是执行类构造器方法的过程。
- 类加载器:包括启动类加载器、扩展类加载器和应用程序类加载器。
10.对象的创建和对象的布局
对象创建的方法:
用new语句创建
调用clone方法,需要实现cloneable
接口
反射:class的newInstance()
反序列化:从文件中获取一个对象的二进制流,使用ObjectInputStream的readObject方法。
对象创建的过程:
类加载检查:判断这个类是不是已经被加载链接初始化了。
为对象分配内存:如果内存规整,虚拟机使用碰撞指针法(指针向空闲区前移对象大小的距离);如果不规整则使用空闲列表法。并发安全:虚拟机维护一个列表记录哪些内存块可用,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表内容。
初始化分配的空间:所有属性初始化为零,保证对象实例字段在不赋值的时候可以直接用
设置对象头信息
执行构造方法初始化
逃逸:方法体内创建的对象,方法体外被其他变量引用过。这样在方法执行完毕之后,该方法中创建的对象不能被GC回收。开启逃逸分析之后,如果对象的作用域仅在方法内,那对象可以创建在虚拟机栈上,随方法入栈创建,出栈销毁,减少GC回收压力。
对象的内存布局:包含三部分:对象头,实例数据和对齐填充。
对象头:运行时数据和类型指针。标记字段包含hashcode
、GC分代年龄
、锁状态标志
、线程持有锁等信息
;类元数据的指针
:可以知道这个对象是哪个类的实例。
实例数据:存储对象真正的数据,也包含父类的数据。
对齐填充:保证对象大小是8字节的整数倍。
11.Java的四种引用(强引用、软引用、弱引用和虚引用)
在jdk1.2之前,Java对引用的定义很传统:如果reference类型的数据中存储的数值是另一块内存的起始地址,就称这块内存代表一个引用。
- 强引用:Java中默认声明的引用为强引用,只要强引用存在,垃圾回收器永远不会回收被引用的对象,哪怕内存不足,JVM也只会抛出OOM错误,不会去回收。
Object obj = new Object();
- 软引用:用于描述一些非必需但仍有用的对象。内存足够的时候,软引用对象不会被回收,只有在内存不足的时候,系统会回收软引用对象,如果内存还是不够才会抛出OOM异常。这种特性使他往往用于实现缓存技术。在
JDK1.2 之后,用java.lang.ref.SoftReference
类来表示软引用。 - 弱引用:弱引用的强度比软引用更弱。无论内存是否足够,只要JVM开始垃圾回收,那些被弱引用关联的对象都会被回收。在 JDK1.2
之后,用java.lang.ref.WeakReference
来表示弱引用。 - 虚引用:最弱的引用关系。与其他几种引用不同,虚引用不会决定对象的生命周期,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,任何时期都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动,且必须与引用队列联合使用。当垃圾回收器准备回收一个对象的时候,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
参考文章
12.内存泄露和内存溢出
- 内存泄漏:一个不再被线程所使用的对象或变量还在内存中占用空间。
- 内存溢出:程序无法申请到足够的内存。
内存泄漏的原因
1.长生命周期的对象持有短生命周期对象的引用。
2.连接未正常关闭。
3.变量作用域设置过大
避免内存泄漏
1.避免在循环中创建对象
2.没有用的对象尽早释放
3.慎用静态变量
4.字符串的拼接使用Stringbuffer/StringBuilder
5.增大xmx和xms的值
内存溢出的原因
1.加载数据过大
2.死循环或过多循环
3.启动参数中内存值设定过小
栈溢出
原因:递归深度过大、局部变量过大
解决:递归不要太深,局部变量改为静态变量
如果排查内存问题
1.JConsole:能看到内存用量的趋势,确定是否有问题
2.GC日志:能看到年轻代和老年代等区域配置是否合理
3.代码中打印内存使用量
4.分析dump文件:针对性的看到发生OOM时候的内存使用量和线程情况
13.List、Set和Map三者的区别和其底层数据结构
List:有序的对象
(1)ArrayList
:数组
(2)Vector
:数组
(3)LinkedList
:双向链表
Set:不允许重复的集合
(1)HashSet
(无序且唯一):基于HashMap
(2)LinkedHashSet
:基于HashMap
(3)TreeSet
(有序且唯一):基于红黑树
Map:使用键值对存储
(1)HashMap
:Jdk1.8之前HashMap
由数组+链表组成,之后再链表长度大于阈值(默认8)时将链表转换为红黑树以减少搜索时间。
(2)LinkedHashMap
:继承自 HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。
(3)HashTable
:数组+链表组成,数组是HashMap
的主体,链表为了解决哈希冲突
(4)TreeMap
:红黑树
ArrayList、LinkedList、Vector
的区别
- 存储结构:
ArrayList
和Vector
是基于数组实现的,而LinkedList
是基于双向链表实现的。 - 线程安全性:
ArrayList
不具有线程安全性(ArrayList
添加元素的操作不是原子操作,可能会出现一个线程的值覆盖另一个线程添加的值的问题),在单线程的环境中,LinkedList
也是不安全的。Vector实现了线程安全,它大部分的关键字都包含synchronized
,但效率低。 - 扩容机制:
ArrayList
和Vector
都是用数组来存储,容量不足的时候可以扩容,ArrayList
扩容后的容量是之前的1.5倍,Vector默认是2倍。Vector
可以设置扩容增量capacityIncrement
。可变长度数组的原理是当元素个数超过数组长度时,产生一个新的数组,将原数组的数据复制到新数组,再将新元素添加到新数组中。 - 增删改查效率:
ArrayList
和Vector
中,从指定的位置检索一个对象,或在末尾插入删除一个元素时间复杂度都是O(1),但是在其他位置增加和删除对象的时间是O(n);LinkedList
,插入删除任何位置的时间都是O(1),但是检索一个元素的时间是O(n)。
14.创建线程的四种方式
继承Thread
类,重写run方法,继承Thread
类的线程类不能再继承其他父类。
实现Runnable
接口,重写run方法
通过Callable
接口和Future
接口创建线程,执行call方法,有返回值可以抛异常
线程池。前三种的线程如果创建关闭频繁的话会消耗系统资源影响性能,而使用线程池可以不用线程的时候放回线程池,用的时候再从线程池取。
15.NIO、AIO和BIO
BIO:传统的网络通讯模型,同步阻塞IO。服务器实现是一个连接一个线程,客户端有连接请求的时候,服务端就要启动一个线程去处理。线程数量可能会爆炸导致崩溃。适用于连接数目小且固定的架构。
NIO:同步非阻塞。服务器实现是一个请求一个线程,客户端发送的连接请求都会注册到多路复用器上,复用器轮询到连接有IO请求才启动线程。适用于连接数目多且连接比较短的架构,比如聊天服务器。
AIO:异步非阻塞。用户进程只需要发起一个IO操作然后立即返回,等IO操作真正完成之后,应用程序会得到IO操作完成的通知。适用于连接数目多且连接长的架构。
16.重写和重载
重写(Override
):重写是子类对父类允许访问的方法实现过程进行重新编写,返回值和形参都不能改变。重写的好处是子类可以根据特定需要,定义特定行为。异常范围可以减少,但是不能抛出新的或更广的异常。
class Animal{
public void move(){
System.out.println("动物可以移动");
}
}
//加入Java开发交流君样:756584822一起吹水聊天
class Dog extends Animal{
public void move(){
System.out.println("狗可以跑和走");
}
}
public class TestDog{
public static void main(String args[]){
Animal a = new Animal(); // Animal 对象
Animal b = new Dog(); // Dog 对象
//加入Java开发交流君样:756584822一起吹水聊天
a.move();// 执行 Animal 类的方法
b.move();//执行 Dog 类的方法
}
}
虽然b属于Animal类型,但是它运行的是Dog
类的move
方法。因为在编译阶段,只是检查参数的引用类型,运行时JVM指定对象的类型并运行该对象的方法。
方法重写规则
(1)参数列表和被重写方法的参数列表必须完全相同。
(2)访问权限不能比父类中被重写的方法访问权限更低。
(3)父类的成员方法只能被它的子类重写。
(4)声明为final的方法不能被重写;声明为
static
的方法不能被重写,但是能被再次声明。(5)构造方法不能被重写。
(6)子类和父类在同一个包中,那么子类可以重写父类中没有声明为private和final的方法;如果不在同一个包中,子类只能重写父类声明为
public
和protected
的非final
方法。
当需要在子类中调用父类的被重写方法时,使用super关键字。
重载(Overload):是在一个类里面,方法名字相同,参数不同的两个方法。返回类型可以相同也可以不同。每个重载的方法(或者构造函数)必须有一个独一无二的参数类型列表。常用于构造器重载。
重载规则
(1)被重载的方法必须改变参数列表。
(2)被重载的方法可以改变返回类型,可以改变访问修饰符,可以声明新的或更广的异常检查。
(3)方法能够在同一个类中或者在一个子类中被重载。
public class Overloading {
public int test(){
System.out.println("test1");
return 1;
}
public void test(int a){
System.out.println("test2");
}
//加入Java开发交流君样:756584822一起吹水聊天
//以下两个参数类型顺序不同
public String test(int a,String s){
System.out.println("test3");
return "returntest3";
}
public String test(String s,int a){
System.out.println("test4");
return "returntest4";
}
public static void main(String[] args){
Overloading o = new Overloading();
System.out.println(o.test());
o.test(1);
System.out.println(o.test(1,"test3"));
System.out.println(o.test("test4",1));
}
}
方法重载和方法重写是java多态的不同表现。
参考文章
17.final/finally/finalize与static
-
final
:java中的关键字,修饰符。如果一个类被声明为final
,就意味着它不能再派生出新的子类,不能作为父类被继承。一个类不能被同时声明final和abstract
抽象类。如果变量或方法被声明为final
,就能保证它们在使用中不被改变,变量必须在声明时赋值,以后的引用中只读,被声明final的方法只能使用,不能重载。 -
finally
:java的一种异常处理机制。java异常处理模型的最佳补充,finally
结构使代码总会执行,而不管有无异常发生。使用finally可以维护对象的内部状态,清理非内存资源。在关闭数据库连接时,如果把数据库连接的close()
方法放到finally中,就会减少出错的可能。 -
finalize
:Java中的一个方法名,该方法是在垃圾收集器将对象从内存中清除出去前,做必要的清理工作。这个方法是由垃圾收集器确定这个对象没被引用的时候调用的。它在Object
类中定义,因此所有类都继承了它。子类可以覆盖该方法来整理资源和清理。 -
static
:static修饰的属性在编译器初始化,初始化之后能改变,final修饰的属性可以在编译器也可以在运行期初始化,但是不能被改变;static
不能修饰局部变量,但是fina
l可以。
18.String、StringBuffer和StringBuilder的区别
String是java编程中广泛使用的,但它的底层实现实际是一个final
类型的字符数组,其中的值不可变,每次对String进行操作就会生成一个新对象,造成内存浪费。
private final char value[];
StringBuffer/StringBuilder
:它们的底层是可变的字符数组,都继承AbstractStringBuilder
抽象类,所以在进行频繁的字符串操作的时候,尽量使用这两个类,它们的区别是:StringBuilder
是线程不安全的,但执行速度较快;StringBuffer
线程安全,但执行速度慢。StringBuffer
使用synchronized关键字进行同步锁。
另外,String类型的比较,“==”是比较两个内存地址是否一样,而“equals
”是比较两个字符串的值是不是一样的。
参考文章
19.如果判断一个对象是否该被回收?
引用计数算法:为对象增加一个引用计数器,当对象增加一个引用的时候+1,引用失效-1,引用计数为0的对象可以被回收。但是当两个对象循环引用的情况下,计数器永远不为0,因此JVM不使用引用计数算法。
可达性分析算法:以GC Roots为起点开始搜索,可达的对象都是存活的,不可达的对象可以被回收,JVM使用该算法进行判断。GC Roots中包含:虚拟机栈中引用的对象、本地方法栈中引用的对象,方法区中静态成员或常量引用的对象。
20.垃圾收集算法
标记-清除算法(Mark-Sweep)
标记阶段:标记的过程实际上就是可达性分析算法过程,遍历GC Roots
对象,可达的对象都做好标记,在对象的header
中将其记录为可达。
清除阶段:对堆进行遍历,如果发现有某个对象没有可达对象标记,则回收。
缺点:两次遍历,效率低;GC运行时需要停止整个程序;产生大量的碎片,需要维护一个空闲列表。
复制算法(Copying)
对象在Survivor
区每经历一次Minor GC
,就将对象年龄+1,当对象年龄达到某个值时,对象复制到老年代,默认为15。JVM中Eden
和Survivor
区的默认比例为8:1:1,保证内存利用率为90%,如果每次回收有多于10%的对象存活,Survivor
空间可能就不够用了,此时借用老年代空间。
缺点:复制收集算法在对象存活率高的时候需要进行很多的复制操作,效率会变低,老年代一般不会用该算法。
标记-整理算法
第一阶段和标记-清楚算法一样,第二阶段将所有存活的对象压缩到内存的另一端,按顺序排放。之后,清理边界外所有的空间。
缺点:效率不高,不仅要标记存活对象,还要整理所有存活对象的引用地址;移动过程中,要全程暂停用户应用程序。
分代收集算法
新生代
:使用复制算法,因为大量对象需要回收。
老年代
:回收的对象很少,所以采用标记清除或者标记整理算法。
21.Double与Float
java语言支持两种基本的浮点类型:float
和double
。32位浮点数float用1位表示符号,8位表示指数,用23位表示尾数;64位浮点数double
用一位表示符号,11位表示指数,52位表示尾数。在表示超过23位的时候,float就会自动四舍五入,这就是float
的精度限制,所以会出现double
可以表示而float会不精确的情况,如果要将这两个浮点数进行转型,java提供了Float.doubleValue()
和Double.floatValue()
方法。使用这个方法在单精度转双精度的时候,会出现偏差。
浮点运算很少是精确的,只要超过精度表示范围就会产生误差。
解决方法:可以通过String
结合BigDecimal
或者通过使用long类型来转换。
参考文章
22.垃圾收集器
查看默认垃圾收集器:-XX:+PrintCommandLineFlags
- Serial串行收集器:单线程收集器,只使用一个线程回收垃圾,需要停掉其他所有线程,Client模式下默认新生代垃圾收集器,新生代使用复制算法,老年代使用标记整理算法,Serial
Old也作为CMS收集器的后备垃圾收集方案。JVM参数:-XX:+UseSerialGC - ParNew收集器:Serial的多线程版本,对应的JVM参数:-XX:+UseParNewGC。开启参数之后,会使用ParNew(新生代)复制算法+Serial
Old(老年代)标记整理算法的组合,Java8之后不再推荐使用这种组合。 - Parallel scavenge收集器:新生代和老年代都使用并行,Parallel scavenge收集器可以使用自适应调节策略,把基本的内存数据设置好,然后设定是更关注最大停顿时间或者更关注吞吐量,给虚拟机设立一个优化目标。JVM参数是:-XX:+UseParallelGC。新生代使用复制算法,老年代使用标记-整理算法。
- CMS收集器:一种以获取最短回收停顿时间为目标的收集器。JVM参数:-XX:+UseConcMarkSweepGC。使用ParNew(新生代)+CMS(老年代)+Serial
Old(后备)的收集器组合。优点是并发收集,停顿少。缺点是并发会造成CPU的压力,而且标记清除算法会产生大量空间碎片。
(1)初始标记:标记GC Roots能直接关联到的对象,速度很快,需要停顿。
(2)并发标记:进行GC Roots Trancing的过程,不需要停顿。
(3)重新标记:修正并发标记期间因为用户程序继续运作而导致变动的那一部分对象重新进行标记,需要停顿。
(4)并发清除:不需要停顿。
G1垃圾收集器:它使得Eden、Survivor和Tenured等内存区域不再连续,而变成一个个大小一样的region,每个region从1M到32M不等。它不再采用CMS的标记清理算法,G1整体上使用标记整理算法,局部上看是基于复制算法。JVM参数:-XX:+UseG1GC。
降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可以预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片内。是因为G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的region。
另:JVM设置参数的方法(win10):环境变量中新建变量JAVA_OPTS,在里面设置。
23.线程池
我们使用线程的时候去创建一个线程,这种方法非常简便,但是会导致一个问题:如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁的创建线程会大大降低系统效率。
Java中引入了线程池来使得线程可以复用,执行完一个任务不会被立刻销毁,而是可以继续执行其他任务。
ThreadPoolExecutor类是线程池技术最核心的类:
其构造器中的参数意义
-
corePoolSize:
核心池大小。在创建线程池之后,默认线程池中是没有线程的,除非调用prestartAllCoreThreads()
或者prestartCoreThread()
方法来预创建线程,就是没有任务到来之前先创建corePoolSize
个线程。当线程池中的线程数目到达corePoolSize
个之后,就会把到达的任务放到缓存序列中。 -
maximumPoolSize
:非常重要的参数,表示线程池中最多能创建多少个线程。 -
keepAliveTime:
表示线程没有任务执行时最多保持多久会终止。 -
unit
:参数keepAliveTime
的时间单位。 -
workQueue:
阻塞队列,用来存储等待执行的任务,会对线程池的运行过程产生重大影响。有三个选择:ArrayBlockingQueue
、LinkedBlockingQueue
和SynchronousQueue
,一般使用后两者。 -
threadFactory
:线程工厂,主要用来创建线程。 -
handler
:表示拒绝处理任务的策略,有四种取值:
(1)ThreadPoolExecutor.AbortPolicy
:丢弃任务抛出RejectedExecutionException
异常;
(2)ThreadPoolExecutor.DiscardPolicy:
丢弃任务,不抛异常
(3)ThreadPoolExecutor.DiscardOldestPolicy
:丢弃队列最前面的任务,然后重新尝试执行任务(重复该过程)
(4)ThreadPoolExecutor.CallRunsPolicy
:由调用线程处理该任务
ThreadPoolExecutor类的方法
execute()
和submit()
:都是提交任务,execute
方法用于提交不需要返回值的任务,无法判断任务是不是被线程池执行成功;submit
提交需要返回值的任务,线程池返回future
类型的对象以判断是否执行成功,future
对象具有的get()方法可以获取返回值。`
shutdown()
和shutdownNow()
:都是关闭线程池,他们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt
方法来中断线程,所以无法响应中断的任务可能永远无法终止。shutdownNow
首先将线程池的状态设置成STOP,然后尝试停止所有正在执行或者暂停的线程,并返回等待执行任务的列表;shutdown
只是将线程池的状态设置为SHUTDOWN
,然后中断所有没有执行任务的线程。
如何合理分配线程池的大小:CPU密集型任务,一般公式为:最大线程数 = CPU核数+1;IO密集型的最大线程数 = CPU核数 * 2;
实现一个线程池:
public class Test {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 200, TimeUnit.MILLISECONDS,new ArrayBlockingQueue(5));
for(int i=0;i<15;i++){
MyTask myTask = new MyTask(i);
executor.execute(myTask);
System.out.println("线程池中线程数目:"+executor.getPoolSize()+",队列中等待执行的任务数目:"+
executor.getQueue().size()+",已执行完别的任务数目:"+executor.getCompletedTaskCount());
}
executor.shutdown();
}
}
线程池不允许使用Executors的静态方法创建,必须通过ThreadPoolExecutor。
线程池的处理流程
当线程池提交一个任务的时候:
(1)线程池判断核心线程池中的线程是不是都在执行任务,如果不是则创建一个新的工作线程执行任务,否则进入流程(2)
(2)线程池判断工作队列是否已满,如果没有满则将新提交的任务存储在这个任务队列中,如果工作队列满了,则进入流程(3)
(3)线程池判断池中的线程是否都处在工作状态,如果没有则创建一个新的工作线程来执行任务,如果已经满了就交给拒绝策略(handler)来处理任务。
参考文章
四种线程池:
(1)newCachedThreadPool 创建一个可以缓存的线程池。
(2)newFixedThreadPool 创建一个定长线程池,可以控制线程最大并发数。
(3)newScheduledThreadPool 创建一个定长线程池,支持定时和周期性任务执行。
(4)newSingleThreadExecutor 创建一个单线程化的线程池,他只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。
//可以缓存的线程池
ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); //需要指定长度
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
详细实现代码
24.线程同步和线程通讯
线程同步的五种方式:synchronized
的关键字修饰方法、静态资源或者代码块;Lock
(必须放在try-catch-finally
中执行,finally
释放锁以防止死锁);wait
和notify
,必须在synchronized
范围内,被synchronized
锁住的对象就是wait和notify
的调用对象;CAS;信号量(Semaphore
)。
线程通讯的方式:
- (1)
wait()、notify()、nofityAll()
:等待/通知机制。线程A调用了对象O的wait
方法进入等待状态,另一个线程B调用了对象O的notify
或notifyAll
方法,线程A收到通知之后,从对象O的wait
方法中返回执行后续操作。调用对象的wait方法会导致线程阻塞,释放该对象的锁;调用对象的notify方法会随机解除该对象阻塞的线程,该线程重新尝试获取该对象的锁;从wait方法返回的前提是获得了调用对象的锁;必须在synchronized
块或方法中使用。 - (2)
condition
:Condition
用await(),signal
,singalAll
方法代替wait和notify
。notify
只能随机唤醒一个线程,但是用condition
可以唤醒指定线程。 - (3)管道
- (4)volatile
- (5)
Thread.join
:如果一个线程执行了Thread.join()
,意味着当前线程A等待thread线程中止之后才从thread.join()
返回。
25.中断线程
调用一个线程的interrupt()
方法来中断线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出InterruptedException
,从而提前结束该线程。
如果线程的run()执行一个死循环,并且没有执行sleep()等会抛出InterruptedException
的操作,那么调用interrupt()
方法无法使线程提前结束。但是调用interrupt
方法会设置线程的中断标记,此时调用Thread.interrupted()
或Thread.currentThread().isInterrupted()
方法会返回true。因此可以在循环体中使用interrupted()方法判断线程是否处于中断状态,从而提前结束线程。
26.Synchronized的用法
线程安全是Java并发编程中的重点,造成线程安全问题主要有两个原因:一是存在共享数据,二是存在多条线程共同操作共享数据。因此,当存在多个线程操作共享数据的时候,需要保证同一时刻有且只有线程在操作共享数据,其他线程必须等到该线程处理完才能进行,这种方式叫做互斥锁。Java中,关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块,同时它还可以保证一个线程(共享数据)的变化被其他线程所看到(可见性保证,完全可以替代Volatile功能)
synchronized是Java的关键字,是一种同步锁。
Java的内置锁(synchronized):每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁。线程进入同步代码块或方法的时候会自动获得该锁,退出同步代码块的时候会释放该锁。获得内置锁的唯一途径就是进入锁保护的同步代码块/方法。
Java的对象锁和类锁:在锁的概念上与内置锁一致,但对象锁是用于对象实例方法或对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。
Java中每个对象都有一把锁和两个队列,一个队列用于挂起未获得锁的线程,一个队列用于挂起条件不满足而等待的线程。synchronized实际上是一个加锁和释放锁的集成。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁,计数归零。线程第一次给对象加锁的时候,计数变成1。每当这个相同的线程在此对象上获得锁的时候,计数就会递增。每当任务离开一个synchronized方法,计数就会递减,为0的时候锁被完全释放。
Synchronized有三种应用方式:
修饰一个实例方法:被修饰的方法称为实例同步方法,其作用范围是整个方法,锁定的事该方法所属的对象(调用该方法的对象)。所有需要获得该对象锁的操作都会对该对象加锁。
public synchronized void method(){}
//等同于
public void method(){
synchronized(this){
}
}
如果一个对象有多个synchronized
方法,只要一个线程访问了其中的一个synchronized
方法,其他线程不能同时访问这个对象中任何一个synchronized
方法。
当一个对象O1在不同的线程中执行这个同步方法的时候,会形成互斥。但是O1对象所属类的另一对象O2是可以调用这个被加了synchronized
关键字的方法的。其他线程调用O2中的相同方法时不会造成同步阻塞。程序可能在这种情况下摆脱同步机制的控制,造成数据混乱。注意:
- (1)
synchronized
关键字不会被继承:子类覆盖父类带synchronized
方法的时候,必须也要给子类的这个方法显式的增加synchronized关键字。 - (2)定义接口的时候不能使用synchronized关键字。
- (3)构造方法不能使用synchronized关键字,但可以使用synchronized代码块完成同步。
修饰一个静态方法:被修饰的方法被称为静态同步方法,其作用域是整个静态方法,锁是静态方法所属的类。
public synchronized static void method(){}
修饰代码块:被修饰的代码块被称为同步语句块。synchronized的括号中必须传入一个对象作为锁,作用范围是大括号中的代码,锁是synchronized括号中的内容,可以分为类锁和对象锁
//锁对象为实例对象
public void method(Object o){
synchronized(o){
...
}
}//加入Java开发交流君样:756584822一起吹水聊天
//锁对象为类的Class对象
public class Demo{
public static void method(){
synchronized(Demo.class){
...
}
}
}
27.Synchronized的原理
实际上是通过monitor
(监视器)。Java中的同步代码块是使用monitorenter
和monitorexit
指令实现的,其中monitorenter
指令插入到同步代码块的开始位置,monitorexit
指令插入同步代码块的结束位置。
JVM保证这两个指令成对出现。
当执行monitorenter
指令的时候,线程试图获取锁也就是获取monitor对象
的所有权,当计数器为0的时候就可以成功获取,获取后将计数器加一。在执行monitorexit
指令之后,将锁计数器减一,表明锁被释放。
synchronized
修饰方法的时候,没有monitorenter
和monitorexit
指令,取而代之的是ACC_SYNCHRONIZED
标识,这个标识指明这个方法是一个同步方法。
28.Synchronized的四种状态
无锁-->偏向锁-->轻量级锁-->重量级锁(过程不可逆)
偏向锁:大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得;如果一个线程获得了锁,锁进入偏向模式,此时对象头的Mark Word
结构也变为偏向锁结构。
对象头在第十章节中提到过,另外这篇文章讲的更详细。
当该线程再次请求锁的时候,只需要检查Mark Word
锁标记为是否为偏向锁,以及当前线程ID是不是等于Mark Word
的Thread Id即可,省去了大量有关锁申请的操作。
偏向锁只适用于只有一个线程访问同步块的场景。
轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。适用于追求响应时间,同步快执行速度非常快的情况。
代码在进入同步块的时候,如果同步对象锁状态是无锁,虚拟机首先在当前线程的栈帧中创建锁记录(Lock Record)
空间,拷贝对象头的Mark Word
复制到锁记录中。
之后虚拟机使用CAS操作尝试将对象的Mark Word
更新为指向Lock Record
的指针,并将Lock Record
的owner指针指向对象的Mark Word。如果这个动作成功了,那么这个线程就有了该对象的锁,对象的锁标记为设置为“00”,说明处于轻量级锁定状态。
如果这个动作失败了,JVM检查对象的Mark Word是否指向当前线程的栈帧,是则说明当前线程已经拥有了这个对象的锁,否则说明多个线程竞争锁。
如果有两个以上的线程竞争同一个锁,轻量级锁不再有效,膨胀为重量级锁。
重量级锁:多线程情况,线程阻塞响应时间缓慢,频繁的释放获取锁会带来巨大的性能损耗。适用于追求吞吐量,同步快执行速度较长的情景。
29.Synchronized与重入锁ReentrantLock的区别
相对与ReentrantLock而言,synchronized锁是重量级的,而且是内置锁,意味着JVM可以对synchronized锁做优化。
在synchronized锁上阻塞的线程是不可中断的,而ReentrantLock锁实现了可中断的阻塞。
synchronized锁释放是自动的,而ReentrantLock需要显式释放(在try-finally块中释放)\
线程在竞争synchronized锁的时候是非公平的:如果synchronized锁被线程A占有,线程B请求失败,被放入队列中,线程C此时来请求锁,恰好A在此时释放了,线程C会跳过队列中等待的线程B直接获得这个锁。但是ReentrantLock可以实现锁的公平性。
synchronized锁是读写和读读都互斥,ReentrankWriteLock分为读锁和写锁,读锁可以同时被多个线程持有,适合于读多写少的并发场景。
ReentrantLock只能锁代码块,但是synchronized可以锁方法和类。ReentrantLock可以知道线程有没有拿到锁,但是synchronized不行。
有关synchronized的参考文章
30.锁优化
在28章节中,我们提到过重量级锁,在重量级锁中,JVM会阻塞未获取到锁的线程,在锁被释放的时候唤醒这些线程,阻塞和唤醒依赖于操作系统,需要从用户态切换到内核态,开销很大。monitor调用了OS底层的互斥量(mutex),切换成本很高。因此JVM引入了自旋的概念。
自旋锁与自适应自旋锁,CAS实现:
- 自旋锁:很多情况下,共享数据的锁定状态持续时间短,切换线程不值得;通过让线程执行忙循环等待锁的释放,不让出CPU,缺点是如果锁被其他线程长时间占用,带来很多开销。
- 自适应自旋锁:自旋的次数不固定,由前一次在同一个锁上的自旋时间和锁的拥有者状态来决定。
- 优点:自旋锁不会使线程状态发生改变,一直处于用户态,不会使线程阻塞,执行速度快。
- CAS(Compare And Swap) 乐观锁与悲观锁:synchronized操作就是悲观锁,这种情况线程一旦得到锁,其他需要锁的线程就挂起的情况是悲观锁;CAS操作实际上是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果失败了就重试,直到成功为止。悲观在认为程序中的并发情况严重,乐观在于并发情况不那么严重,可以多次尝试。
- 锁消除:虚拟机在即时编译器运行时,对一些代码上要求同步而被检测到实际不可能存在共享数据竞争的锁进行消除。依据是:JVM会判断一段程序中的同步明显不会逃逸出去从而被其他线程访问,JVM就把它们当作栈上的数据对待,认为这些数据是线程独有的。
- 锁粗化:在加同步锁的时候,我们尽量的把同步块的作用范围限制到尽量小的范围。但是如果存在一连串的操作都对同一个对象反复加锁解锁,甚至加锁出现在循环体内,即使没有线程竞争,频繁的进行互斥同步也会导致消耗。
public static String test04(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
上述连续的append操作就属于这类情况,jvm检测到一连串操作都是对同一个对象加锁,就会把锁同步范围扩展(粗化)到整个一系列操作的外部,使得一连串append操作只需要加一次锁就可以了。
31.Java设计模式
设计模式是一套被反复使用,多数人知晓的,经过分类编目的,代码设计经验的总结。使用设计模式是为了可重用代码,让代码更容易被他人理解。实际上就是在某些场景下,针对某类问题的某种通用的解决方案。
设计模式分为三类:
- (1)创建型模式:对象实例化的模式,创建型模式用于解耦对象的实例化过程。包括单例模式、简单工厂、抽象工厂等。
- (2)结构型模式:把类和对象结合在一起形成一个更大的结构。包括适配器模式、组合模式、装饰模式等。
- (3)行为型模式:类和对象如何交互、及划分责任和算法。包括模板模式、解释器模式、观察者模式等。
单例模式:属于创建型模式,主要有三种写法:懒汉式、饿汉式和登记式。
单例模式的特点:
- (1)单例类只能有一个实例
- (2)单例类必须自己创建自己的唯一实例
- (3)单例类必须给所有其他对象提供这一实例
懒汉式:在第一次调用的时候就实例化自己。
public class Singleton{
private Singleton(){}
private static Singleton single = null;
//静态工厂方法
private static Singleton getInstance(){
if(single == null) single = new Singleton();
}
return single;
}
懒汉式并不考虑线程安全问题,所以他是线程不安全的,并发情况下很可能出现多个Singleton
实例,要实现线程安全,有以下三个方式:
在getInstance
方法上加同步关键字:在并发环境下,多个一起进入getInstance
里,因为还没有实例化单例模式,single都是null,就会创建多个Singleton实例化对象,破坏了单例模式想要的结果。我们可以在getInstance
方法上加synchronized
锁。
public static synchronized Singleton getInstance(){
if(single == null) single = new Singleton();
return single;
}
双重校验锁定:
public static Singleton getInstance(){
if(singleton == null){
synchronized (Singleton.class){
if(singleton == null) singleton = new Singleton();
}
}
return singleton;
}
双重校验锁定的单例仍然需要再加上volatile确保线程安全。
静态同步类:即实现了线程安全,又避免了同步带来的性能影响。
public class Singleton{
private static class LazyHolder{
private static final Singleton INSTANCE = new Singleton();
}
private Singleton(){}
public static final Singleton getInstance(){
return LazyHolder.INSTANCE;
}
}
饿汉式:饿汉式在类创建的同时就已经创建好了一个静态的对象供系统使用,以后不再改变,所以天生是线程安全的。
public class Singleton1{
private Singleton1(){}
private static final Singleton1 single = new Singleton1();
//静态工厂方法
public static Singleton1 getInstance(){
return single;
}
}
饿汉就是类一旦加载,就把单例初始化完成,保证getInstance的时候,单例已经存在了;而懒汉比较懒,只有用户调用getInstance的时候,才会初始化这个实例。
总结
生命不止坚毅鱼奋斗,有梦想才是有意义的追求
给大家推荐一个免费的学习交流君样:756584822
最后,祝大家早日学有所成,拿到满意offer,快速升职加薪,走上人生巅峰。
Java开发交流君样:756584822