面试
数据结构和算法基础
常见的排序算法和分别的复杂度
- 冒泡排序
- 时间复杂度:最坏O
(n²)
,平均O(n²)
- 空间复杂度:O
(1)
- 时间复杂度:最坏O
- 选择排序
- 时间复杂度:最坏O
(n²)
,平均O(n²)
- 空间复杂度:O
(1)
- 时间复杂度:最坏O
- 归并排序
- 时间复杂度:最坏O
(nlogn)
,平均O(nlogn)
- 空间复杂度:O
(n)
- 时间复杂度:最坏O
- 快速排序
- 时间复杂度:最坏O
(nlogn)
,平均O(n²)
- 空间复杂度:O
(logn)
- 时间复杂度:最坏O
排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|
冒泡排序 | O(n²) | O(n²) | O(1) | 是 |
选择排序 | O(n²) | O(n²) | O(1) | 不是 |
直接插入排序 | O(n²) | O(n²) | O(1) | 是 |
归并排序 | O(nlogn) | O(nlogn) | O(n) | 是 |
快速排序 | O(nlogn) | O(n²) | O(logn) | 不是 |
堆排序 | O(nlogn) | O(nlogn) | O(1) | 不是 |
希尔排序 | O(nlogn) | O(n²) | O(1) | 不是 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | 是 |
基数排序 | O(N*M) | O(N*M) | O(M) | 是 |
- 有常数阶,对数阶,线性阶,线性对数阶,平方阶,立方阶,k次方阶和指数阶等。
- 随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低。
冒泡排序
// 冒泡排序
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr.length - i - 1; j++) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
链式存储结构
- 链式存储结构的内存地址不一定是连续的。
- 每一个节点上不止是存储着数据,还要另开辟一点空间存储下个节点的地址
- 适用于在较频繁地插入、删除、更新元素,因为只需相应的改变节点中存储的地址即可。
如何遍历一个二叉树
有三种方式可以遍历:前序遍历,中序遍历,后序遍历。
- 前序遍历: 先遍历根节点,再遍历左子树,最后遍历右子树。子树中继续前序遍历。
- 中序遍历: 先遍历左子树,再遍历根节点,最后遍历右子树。子树中继续中序遍历。
- 后序遍历: 先遍历左子树,再遍历右子树,最后遍历根节点。子树中继续后序遍历。
倒排一个LinkedList
先将数据存储一个栈中,然后再从中将数据取出,即完成倒排
递归遍历目录下面的所有文件。
public calss Recursion {
public static void main(String[] args) {
// 取得目标目录
File file = new File("D:");
// 获取该目录下文件子文件夹
File[] files = file.listFiles();
this.readfile(files);
}
private void readfile(File[] files) {
// 如果目录为空,则表示该路径不存在,终止函数
if (files == null) return;
for (File file : files) {
// 如果是文件,则直接输出名字
if (file.isFile()) {
System.out.println(f.getName());
} else if (file.isDirectory()) {// 如果是文件夹,递归调用
this.readFile(file);
}
}
}
}
Java基础
接口和抽象类的区别
- 两者都是抽象的一种体现,只是接口倾向对功能的抽象,而抽象类是倾向对特征的抽象。
- 抽象类和接口都不能被实例化,但都可以有实现/继承它们的子类。
- 抽象类可以被继承,接口则既可以被继承也可以被实现,但继承只能被接口继承。
- 接口只能做方法的声明,即抽象方法。但在
JDK8
中引入了默认方法,在JDK9
中引入了私有方法。抽象类既可以有抽象方法,也可以由实现的方法 - 接口中的成员变量必须为常量,或者被
final
修饰。抽象类则没有这个必要。 - 抽象类和接口都可以没有抽象方法
- 抽象类里的抽象方法必须全部被子类所实现,如果子类不能全部实现父类抽象方法,那么该子类只能是抽象类。同样,一个实现接口的时候,如不能全部实现接口方法,那么该类也只能为抽象类。
Java中的异常有哪几类?分别怎么使用?
Throwable
Error:
是程序无法处理的。如果出现,java
虚拟机一般会终止线程。Exception:
RuntimeException:
运行时异常,又称非检查异常。在程序运行的时候可能会发生,所以程序可以捕捉,也可以不捕捉,这些错误一般是由程序的逻辑错误引起的,必须避免。Exception:
非运行时异常,又称检查异常。必须通过捕捉来检查处理的,如果不进行处理也必须向上抛出,抛给它的调用者,让其处理。
常见的集合类,List如何排序
Collection
List:
存储有序,且允许重复ArrayList:
底层基于数组实现,增删慢,查询快LinkedList:
底层基于链表实现,增删快,查询慢
Set:
存储无序,且不允许重复HashSet:
基于HashMap
实现,存储无序LinkedHashSet:
基于HashMap
+ 链表实现,所以存储有序。
Map
HashMap:
存储无序,且可以有null
值,但是key
值只能有一个null
。SortedMap
其实现类:TreeMap
,基于二叉树实现
排序的基本原理都是借助Comparable
接口和Comparator
接口的实现。
== 和equals的区别
==:
比较的是变量(栈)内存中存放的对象的(堆)内存地址,用来判断两个对象的地址是否相同,即是否是指相同一个对象。比较的是真正意义上的指针操作。- 比较的是操作符两端的操作数/对象是否是同一个对象;
- 两边操作数必须是同一类型,可以是多态,才能编译通过;
- 比较的是地址,如果是具体的阿拉伯数字的比较,则值相等为true。
equals:
equals
用来比较的是两个对象的内容是否相等,由于所有的类都是继承自Object
类的,所以适用于所有对象,如果没有对该方法进行覆盖的话,调用的仍然是Object
类中的方法,而Object
中的equals
方法返回的却是==
的判断。 对equals
重写需要注意以下5
点:- 自反性: 对任意引用值
X
,x.equals(x)
的返回值必须为true
。 - 对称性: 对于任何引用值
x、y
,当且仅当y.equals(x)
返回值为true
时,x.equals(y)
的返回值一定为true
; - 传递性: 如果
x.equals(y)=true
,y.equals(z)=true
,则x.equals(z)=true
; - 一致性: 如果参与比较的对象没任何改变,则对象比较的结果也不应该有任何改变;
- 非空性: 任何非空的引用值
x
,x.equals(null)
的返回值一定为false
。
- 自反性: 对任意引用值
NIO模型,select/epoll区别,多路复用的原理
- 多路复用
IO
原理:通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select, poll, epoll
本质上都是同步I/O
,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O
则无需自己负责进行读写,异步I/O
的实现会负责把数据从内核拷贝到用户空间。 - 阻塞与非阻塞
- 阻塞方式
block:
就是进程或是线程执行到这些函数时必须等待某个事件的发生,如果事件没有发生,进程或线程就被阻塞,函数不能立即返回。 - 非阻塞方式
non-block:
进程或线程执行此函数时不必非要等待事件的发生,一旦执行肯定返回,以返回值的不同来反映函数的执行情况,如果事件发生则与阻塞方式相同,若事件没有发生,则返回一个代码来告知事件未发生,而进程或线程继续执行,所以效率较高。
- 阻塞方式
select:
提供一种fd_set
的数据结构,实际上是一个long
类型的数组, 每一个数组元素都能与一打开的文件句柄(不管是Socket
句柄,还是其他 文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成, 当调用select()
时,由内核根据IO
状态修改fd_set
的内容,由此来通知执行了select()
的进程哪一Socket
或文件可读或可写。主要用于Socket
通信当中。select
的几大缺点- 每次调用
select
,都需要把fd
集合从用户态拷贝到内核态,这个开销在fd
很多时会很大; - 同时每次调用
select
都需要在内核遍历传递进来的所有fd
,这个开销在fd
很多时也很大; select
支持的文件描述符数量太小了,默认是1024
。
- 每次调用
epoll
、select
和poll
的调用接口上的不同,select
和poll
都只提供了一个函数,select
或者poll
函数。而epoll
提供了三个函数,epoll_create
、epoll_ctl
和epoll_wait
,epoll_create
是创建一个epoll
句柄;epoll_ctl
是注册要监听的事件类型;epoll_wait
则是等待事件的产生。- 对于
select
第一个缺点,epoll
的解决方案在epoll_ctl
函数中。每次注册新的事件到epoll
句柄中时(在epoll_ctl
中指定EPOLL_CTL_ADD
),会把所有的fd
拷贝进内核,而不是在epoll_wait
的时候重复拷贝。epoll
保证了每个fd
在整个过程中只会拷贝一次。 - 对于
select
第二个缺点,epoll
的解决方案不像select
或poll
一样每次都把current
轮流加入fd
对应的设备等待队列中,而只在epoll_ctl
时把current
挂一遍(这一遍必不可少)并为每个fd
指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd
加入一个就绪链表)。epoll_wait
的工作实际上就是在这个就绪链表中查看有没有就绪的fd
。 - 对于
select
第三个缺点,epoll
没有这个限制,它所支持的FD
上限是最大可以打开文件的数目,这个数字一般远大于2048
。举个例子,在1GB
内存的机器上大约是10
万左右,具体数目可以cat /proc/sys/fs/file-max
察看,一般来说这个数目和系统内存关系很大。
- 对于
- 总结
select, poll
实现需要自己不断轮询所有fd
集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll
其实也需要调用epoll_wait
不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd
放入就绪链表中,并唤醒在epoll_wait
中进入睡眠的进程。虽然都要睡眠和交替,但是select
和poll
在醒着的时候要遍历整个fd
集合,而epoll
在醒着的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU
时间。这就是回调机制带来的性能提升。select, poll
每次调用都要把fd
集合从用户态往内核态拷贝一次,并且要把current
往设备等待队列中挂一次,而epoll
只要一次拷贝,而且把current
往等待队列上挂也只挂一次(在epoll_wait
的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll
内部定义的等待队列)。这也能节省不少的开销。
基本类型占用的字节数
- 字符占用
2
个字节;布尔类型占用1
个字节 byte
占用1
个字节,short
占用2
个字节,int
占用4
个字节,long
占用8
个字节,float
占用4
个字节,double
占用8
个字节
创建一个实例的方法
new
一个对象;- 调用
clone()
方法,克隆出一个对象; - 使用反射技术:
a.getClass.newInstance()
方法创建一个对象; - 在
JDK1.8
以后,可以使用方法引用的方式创建一个对象,但实质调用的还是上述三种方法来创建对象。
final/finally/finalize的区别
final:
用于修饰类、成员变量和成员方法。final
修饰的类,不能被继承(String、StrngBuilder、StringBuffer、Math,不可变类)
,其中所有的方法都不能被重写,所以不能同时用abstract
和final
修饰(abstract
修饰的是抽象类,抽象类是用于被子类继承的,和final
起相反的作用);final
修饰的方法不能被重写,但是子类可以用父类中final
修饰的方法;final
修饰的成员变量是不可变的,如果成员变量是基本数据类型,初始化之后成员变量的值不能被改变,如果成员变量是引用类型,那么它只能指向初始化时指向的那个对象,不能再指向别的对象,但是对象中的内容是允许改变的。
finally:
finally
是在异常处理时提供finally
块来执行任何清除操作。不管有没有异常被抛出、捕获都会被执行。try
块中的内容是在无异常时执行到结束。catch
块中的内容,是在try
块内容发生catch
所声明的异常时,跳转到catch
块中执行。finally
块则是无论异常是否发生都会执行finally
块的内容,所以在代码逻辑中有需要无论发生什么都必须执行的代码,可以放在finally
块中。
finalize:
finalize
是方法名,java
技术允许使用finalize()
方法在垃圾收集器将对象从内存中清楚出去之前做必要的清理工作。- 这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的,它是在
Object
类中定义的,因此所有的类都继承了它。 - 子类覆盖
finalize()
方法以整理系统资源或者执行其他清理工作。 finalize()
方法是在垃圾收集器删除对象之前对这个对象调用的。
- 这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的,它是在
如何用Java分配一段连续的1G的内存空间
ByteBuffer.allocateDirect(1024 * 1024 * 1024);
- 注意内存是否还有那么大的内存空间。
序列化和反序列化
- 序列化: 把
Java
对象转换为字节序列的过程;**反序列化:**把字节序列恢复为Java
对象的过程。 - 序列化就是一种用来处理对象流的机制,所谓**对象流也就是将对象的内容进行流化。**可以对流化后的对象进行读写操作,也可将流化后的对象传输于网络之间。
- 序列化是为了解决在对对象流进行读写操作时所引发的问题。
- 将需要被序列化的类实现
Serializable
接口,该接口没有需要实现的方法,implements Serializable
只是为了标注该对象是可被序列化的,然后使用一个输出流来构造一个ObjectOutputStream(对象流)
对象,接着,使用ObjectOutputStream
对象的writeObject(Object obj)
方法就可以将参数为obj
的对象写出(即保存其状态)
,要恢复的话则用输入流。 - 当两个进程在进行远程通信时,彼此可以发送各种类型的数据。无论是何种类型的数据,都会以二进制序列的形式在网络上传送。发送方需要把这个
Java
对象转换为字节序列,才能在网络上传送;接收方则需要把字节序列再恢复为Java
对象。只能将支持java.io.Serializable
接口的对象写入流中。每个serializable
对象的类都被编码,编码内容包括类名和类签名、对象的字段值和数组值,以及从初始对象中引用的其他所有对象的闭包。 - 用途: 把对象的字节序列永久地保存到硬盘上,通常存放在一个文件中;在网络上传输对象的字节序列。
- 注意
- 读取对象的顺序要与写入时的顺序一致;
- 对象的默认序列化机制写入的内容是:对象的类,类签名,以及非瞬态和非静态字段的值。
String s = new String(“a”);创建了几个 String Object
- 如果字符串常量池中不存在该值,则创建两个;如果存在就创建一个对象。
JVM
JVM堆的基本结构
- 年轻代: 年轻代的目标就是尽可能快速地收集掉那些生命周期短的对象。年轻代分为三个区:一个
Eden
区,两个Survior
区。当Eden
区满时,调用垃圾回收机制,还存活的对象会被复制到一个Survior
区中,记为from
区;当这个from
区满时,调用垃圾回收机制,将还存活的对象复制到另一个Survior
区,记为to
区。当to
区满时,则将存活的对象晋升到年老代。 - 年老代: 在年轻代中经历了
N
次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。 - 持久代: 用于存放静态文件,如今
Java
类、方法、静态变量等。持久代对垃圾回收没有显著影响。 - 在
JDK1.8
中,永久代已经从Java
堆中移除。String
直接存放在堆中,**类的元数据存储在meta space
中,meta space
占用外部内存,不占用堆内存。**可以说,在java8
的新版本中,持久代已经更名为了元空间(meta space)
。
垃圾算法种类,CMS垃圾回收的基本流程
- 种类
- 串行收集器
- 并行收集器
CMS
回收器G1
回收器
CMS
:Concurrent Mark Sweep
,并发标记清除- 并发表示它可以与应用程序并发执行、交替执行。 标记清除表示这种回收器不是使用的是标记压缩算法。需要注意的是
CMS
回收器是一种针对老年代的回收器,不对新生代产生作用,并且分为好几个阶段来执行GC
。在某些阶段,应用的线程会被挂起,也就是stop-the-world
。而在另外的阶段里,垃圾回收线程可以与应用的线程一起工作。 - 这种回收器优点在于减少了应用程序停顿的时间,因为它不需要应用程序完成暂定等待垃圾回收,而是与垃圾回收并发执行。
- 特点:
- 希望
Java
垃圾回收器回收垃圾的时间尽可能的短; - 应用运行在多
CPU
的机器上,有足够的CPU
资源; - 有比较多生命周期长的对象;
- 希望应用的响应时间短。
- 希望
- 主要流程
- 初始标记
(CMS-initial-mark)
: 标记从GC Root
可以直接可达的对象; - 并发标记
(CMS-concurrent-mark)
: 主要标记过程,标记全部对象; - 重新标记
(CMS-remark):
在正式清理前,重新标记,用于修正; - 并发清除
(CMS-concurrent-sweep)
: 基于标记结果,直接清理对象。 - 并发重置状态等待下次
CMS
的触发(CMS-concurrent-reset)
- 初始标记
CMS
垃圾回收过程会把应用线程挂起两次CMS-initial-mark:
这个阶段会扫描root
对象直接关联的可达对象。注意不会递归的追踪下去,只是到达第一层而已。这个过程,会STW
,但是时间很短。CMS-remark:
在并发mark
阶段,应用的线程可能产生新的垃圾,所以需要重新标记,这个阶段也是会STW
。
- 缺点
- 由于
CMS
垃圾回收线程可以和应用的线程一起工作,那么应用线程仍然需要申请内存,如果这个时候老年代的空间已经不够用了。那么会有Concurrent Mode Failure
这样的日志输出,之后会进行一次Full GC
的操作,所有的应用线程都会停止工作。 - 浮动垃圾: 由于
CMS
垃圾回收线程可以跟应用的线程一起工作,那么应用的线程也会产生垃圾,这些称之为浮动垃圾。 - 降低吞吐量: 由于应用线程和垃圾回收线程一起工作,那么垃圾回收线程也就抢占了系统资源,会对应用的吞吐量造成一定的影响。为了保证垃圾回收过程中,应用线程有足够的内存可以使用,当堆内存的空间使用率达到
68%
的时候,CMS
开始触发垃圾回收。 - 内存碎片:
CMS
是基于标记-清除算法的,会造成内存碎片。
- 由于
- 并发表示它可以与应用程序并发执行、交替执行。 标记清除表示这种回收器不是使用的是标记压缩算法。需要注意的是
JVM有哪些常用启动参数可以调整,描述几个
-Xms:
设置JVM
内存的初始大小;-Xmx:
设置JVM
内存的最大值;-Xmn:
设置新域的大小-Xss:
设置每个线程的堆栈大小 (也就是说,在相同物理内存下,减小这个值能生成更多的线程)-XX:NewRatio:
设置新域与旧域之比,如-XX:NewRatio=4
就表示新域与旧域之比为1:4
-XX:NewSize:
设置新域的初始值-XX:MaxNewSize:
设置新域的最大值-XX:MaxPermSize:
设置永久域的最大值-XX:SurvivorRatio=n:
设置新域中Eden
区与两个Survivor
区的比值。
Java程序中内存溢出,内存泄露
- 一般来说内存泄漏有两种情况。
- 一种情况如在
C/C++
语言中的,在堆中的分配的内存,在没有将其释放掉的时候,就将所有能访问这块内存的方式都删掉(如指针重新赋值); - 二则是在内存对象明明已经不需要的时候,还仍然保留着这块内存和它的访问方式(引用)。
- 一种情况如在
- 第一种情况,在
Java
中已经由于垃圾回收机制的引入,得到了很好的解决。所以,Java
中的内存泄漏,主要指的是第二种情况。 - 对于
Java
,内存溢出分三种情况:OutOfMemoryError: PermGen space
Permanent Generation space
这个区域主要用来保存加来的Class
的一些信息,在程序运行期间属于永久占用的,Java
的GC
不会对他进行释放,所以如果启动的程序加载的信息比较大,超出了这个空间的大小,就会发生溢出错误;- 解决的办法无非就是增加空间分配了——增加
Java
虚拟机中的XX:PermSize
和XX:MaxPermSize
参数的大小,其中**XX:PermSize
是初始永久保存区域大小,XX:MaxPermSize
是最大永久保存区域大小。**
OutOfMemoryError: Java heap space
heap
是Java
内存中的堆区,主要用来存放对象,当对象太多超出了空间大小,GC
又来不及释放的时候,就会发生溢出错误。Java
中对象的创建是可控的,但是对象的回收是由GC
自动的,一般来说,当已存在对象没有引用(即不可达)的时候,GC
就会定时的来回收对象,释放空间。但是因为程序的设计问题,导致对象可达但是又没有用(即前文提到的内存泄露)
,当这种情况越来越多的时候,问题就来了。- 解决办法
- 检查程序,减少大量重复创建对象的死循环,减少内存泄露。
- 增加
Java
虚拟机中Xms
(初始堆大小)和Xmx
(最大堆大小)参数的大小。
StackOverFlowError
Stack
是Java
内存中的栈空间,主要用来存放方法中的变量,参数等临时性的数据的,发生溢出一般是因为分配空间太小,或是执行的方法递归层数太多创建了占用了太多栈帧导致溢出。- 针对这个问题,除了修改配置参数
-Xss
参数增加线程栈大小之外,优化程序是尤其重要。
JVM的内存结构
- 内存空间:是在
JVM
运行的时候操作所分配的内存区。运行时内存主要可以划分为以下结果区域:- 方法区
(Method Area)
:是各个线程共享的区域,存储类结构信息的地方,包括常量池、静态变量、构造函数等。虽然JVM
规范把方法区描述为堆的一个逻辑部分, 但它却有个别名non-heap
(非堆)。方法区还包含一个运行时常量池。 Java
堆(Heap)
:也是**线程共享的区域,存储Java
实例或者对象的地方。**这里GC
的主要区域。Java
栈(Stack)
:每个线程私有的区域,它的生命周期与线程相同。每执行一个方法就会往栈中压入一个元素,这个元素叫栈帧,用于存储局部变量表、操作栈、方法返回值等。每一个方法从调用直至执行完成的过程,就对应一个栈帧在java
栈中入栈到出栈的过程。- 本地方法栈
(Native Method Stack):
和Java
栈类似,只不过是为JVM
使用到的native
方法服务的 - 程序寄数器
(PC Register)
:每个线程私有的区域,用于保存当前线程执行的内存地址。由于JVM
程序是多线程执行的(线程轮流切换),所以为了保证线程切换回来后,还能恢复到原先状态,就需要一个独立的计数器,记录之前中断的地方。还有程序该怎么执行,哪个方法先执行,哪个方法后执行,这些指令执行的顺序就是程序寄数器在管,它的作用就是控制程序指令的执行顺序。 执行引擎就是根据程序寄数器调配的指令顺序,依次执行程序指令。
- 方法区
GC策略,YGC和FGC
- 常见的内存回收策略
- 串行
&
并行- 串行:单线程执行内存回收。十分简单,无需考虑同步等问题,但耗时较长,不适合多
CPU
。 - 并行:多线程并发的执行内存回收。适合多
CPU
,效率高。
- 串行:单线程执行内存回收。十分简单,无需考虑同步等问题,但耗时较长,不适合多
- 并发
& stop the world
stop the world:
Jvm
里的应用线程会挂起,只有垃圾回收线程在工作进行垃圾清理工作。简单,无需考虑回收不干净等问题。- 并发:在垃圾回收的同时,应用也在跑,保证应用的响应时间。存在回收不干净需要二次回收的情况
- 压缩
&
非压缩& copy
- 压缩:在进行垃圾回收后,会通过滑动,把存活对象滑动到连续的空间里,清理碎片,保证剩余的空间是连续的。
- 非压缩:保留碎片,不进行压缩。
copy
:将存活对象移到新空间,老空间全部释放。(需要较大的内存)
- 串行
YGC
和FGC
YGC:
对新生代堆进行GC
。频率较高,因为大部分对象的存活寿命较短,在新生代里被回收。性能耗费较小。- 触发
YGC的时机:eden
空间不足。当eden
空间不足,这时候肯定会触发YGC
。
- 触发
FGC:
全堆范围的GC
。默认堆空间使用达到80%(可调整)
的时候会触发FGC
。- 触发
FGC
的时机:old
空间不足;perm
空间年不足;显示调用System.gc()
等。 - 当创建一个新的对象过大,大到
eden
区的上限并且比old
现有空间还要大时,也会触发FGC
。
- 触发
多线程/并发
线程的创建
- 创建线程的方式有
4
种- 继承
Thread
类创建线程- 定义
Thread
类的子类,并重写该类的run()
方法,该方法的方法体就是线程需要完成的任务 - 创建
Thread
子类的实例,也就是创建了该线程对象 - 启动线程,即调用线程的
start()
方法
- 定义
- 实现
Runnable
接口创建线程- 定义
Runnable
接口的实现类,一样要重写run()
方法,这个run()
方法和Thread
中的run()
方法一样是线程的执行体 - 创建
Runnable
实现类的实例,并用这个实例作为Thread
的target
来创建Thread
对象,这个Thread
对象才是真正的线程对象 - 第三步依然是通过调用线程对象的
start()
方法来启动线程
- 定义
- 使用
Callable
和Future
创建线程- 实现
Callable
接口,实现call()
方法,然后创建该实现类的实例。 - 使用
FutureTask
类来包装Callable
对象,该FutureTask
对象封装了Callable
对象的call()
方法的返回值 - 使用
FutureTask
对象作为Thread
对象的target
创建并启动线程**(因为FutureTask
实现了Runnable
接口)。** - 调用
FutureTask
对象的get()
方法来获得子线程执行结束后的返回值。
- 实现
- 使用线程池创建线程
- 继承
- 四种方式对比
- 实现
Runnable
和实现Callable
接口的方式基本相同,不同是后者执行call()
方法有返回值,前者线程执行体run()
方法无返回值,因此可以把这两种方式归为一种。这种方式与继承Thread
类的方法之间的差别如下:- 线程只是实现
Runnable
或实现Callable
接口,还可以继承其它类。这种方式下,多个线程可以共享一个target
对象,非常适合多线程处理同一份资源的情形。但是编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()
方法。 - 继承
Thread
类的线程类不能再继承其它父类。 - 前三种的线程如果创建关闭频繁会消耗系统资源影响性能,而使用线程池可以不用线程的时候放回线程池,用的时候再从线程池取,项目开发中主要使用线程池。
- 线程只是实现
- 实现
如何保证线程安全
Java
语言中,按照线程安全的安全程度由强到弱来排序,操作共享的数据可分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。- 不可变:不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。如
final
关键字修饰的数据不可修改,可靠性最高。 - 绝对线程安全: 绝对的线程安全完全满足
Brian GoetZ
给出的线程安全的定义,这个定义其实是很严格的,一个类要达到不管运行时环境如何,调用者都不需要任何额外的同步措施通常需要付出很大的代价。 - 相对线程安全:相对线程安全就是我们通常意义上所讲的一个类是线程安全的。它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但对于一些特定顺序的连续调用,就可能要在调用端使用额外的同步手段来保证调用的正确性。在
Java
中,大部分的线程安全类都属于相对线程安全的,如Vector
、HashTable
、Collections
的synchronizedCollection()
方法保证的集合。 - **线程兼容:线程兼容就是我们通常意义上所讲的一个类不是线程安全的。**线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境下可以安全地使用。
Java API
中大部分的类都是属于线程兼容的。如集合类ArrayList
和HashMap
等。 - **线程对立:**线程对立是指无论调用端是否采取了同步错误,都无法在多线程环境中并发使用的代码。由于
Java
语言天生就具有多线程特性,线程对立这种排斥多线程的代码是很少出现的。
- 不可变:不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施。如
- 线程安全的实现方法:以是否需要同步手段分类,分为同步方案和无需同步方案。
- 互斥同步,又称阻塞同步
- 同步**是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。**而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这
4
个字里面,互斥是因,同步是果;互斥是方法,同步是目的。 - 在
Java
中,最基本的互斥同步手段就是synchronized
关键字,synchronized
关键字编译之后,会在同步块的前后分别形成monitorenter
和monitorexit
这两个字节码质量,这两个字节码指令都需要一个reference
类型的参数来指明要锁定和解锁的对象。 ReentrantLock
也是通过互斥来实现同步。在基本用法上,ReentrantLock
与synchronized
很相似,他们都具备一样的线程重入特性。- 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要加锁。
- 同步**是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。**而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这
- 非阻塞同步
- 随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
- 非阻塞的实现
CAS(compareandswap):CAS
指令需要有3
个操作数,分别是内存地址(在Java
中理解为变量的内存地址,用V
表示)、旧的预期值(用A
表示)和新值(用B
表示)。CAS
指令执行时,CAS
指令指令时,当且仅当V
处的值符合旧预期值A
时,处理器用B
更新V
处的值,否则它就不执行更新,但是无论是否更新了V
处的值,都会返回V
的旧值,上述的处理过程是一个原子操作。 CAS
的缺点ABA
问题:因为CAS
需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是一个值原来是A
,变成了B
,又变成了A
,那么使用CAS
进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA
问题的解决思路就是使用版本号。在变量前面追加版本号,每次变量更新的时候把版本号加一,那么A-B-A
就变成了1A-2B-3C
。JDK
的atomic
包里提供了一个类AtomicStampedReference
来解决ABA
问题。这个类的compareAndSet
方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 无需同步方案
- 要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步操作去保证正确性,因此会有一些代码天生就是线程安全的。
- 可重入代码
- 可重入代码
(ReentrantCode)
也称为纯代码(Pure Code)
,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。 - 可重入代码的特点是不依赖存储在堆上的数据和公用的系统资源、用到的状态量都是由参数中传入、不调用非可重入的方法等。
- 可重入代码
- 线程本地存储
- 如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内。这样无需同步也能保证线程之间不出现数据的争用问题。
- 符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如生产者-消费者模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典的
Web
交互模型中的**一个请求对应一个服务器线程(Thread-per-Request
)**的处理方式,这种处理方式的广泛应用使得很多Web
服务器应用都可以使用线程本地存储来解决线程安全问题。
- 互斥同步,又称阻塞同步
如何实现一个线程安全的类
- 设置标记位。 访问方法先判断标记为是否为
false
,是的话改为true
,运行完再修改为false
。
如何实现一个线程安全的数据结构
- 加同步
(synchronized)
。 - 使用
final
关键字修饰。
如何避免死锁
-
死锁: 两个或多个线程互相持有对方锁需要的资源,导致各自线程处于等待状态,如果线程不主动释放所占的资源,将产生死锁。
-
死锁的四个必要条件:
- 互斥条件: 一个资源每次只能被一个线程使用;
- 请求和保持条件: 一个线程因请求资源而阻塞时,对方已经获得的资源保持不放;
- 不可抢占条件: 线程已获得的资源,在未使用完之前,不能强行剥夺,只能在其使用完时自己释放;
- 循环等待条件: 若干线程之间形成一种头尾相连的循环等待资源关系。
这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
-
避免死锁的方法:
- 原理:1. 破坏死锁的四个必要条件,便可以有效地避免死锁。2. 系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配,这是一种保证系统不进入死锁状态的动态策略。
- 加锁顺序:
- 例如加锁顺序是
A -> B -> C
,现在想要线程C
想要获取锁,那么他必须等到线程A
和线程B
获取锁之后才能轮到他获取。 - 按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁,并知道他们之间获取锁的顺序是什么样的。
- 例如加锁顺序是
- 加锁时限:
- 若一个线程在一定的时间里没有成功的获取到锁,则会进行回退并释放之前获取到的锁,然后等待一段时间后进行重试。在这段等待时间中其他线程有机会尝试获取相同的锁,这样就能保证在没有获取锁的时候继续执行比的事情。
- 有时为了执行某个任务。某个线程花了很长的时间去执行任务,如果在其他线程看来,可能这个时间已经超过了等待的时限,可能出现了死锁。
- 死锁检测:
- 当一个线程获取锁的时候,会在相应的数据结构中记录下来,相同下,如果有线程请求锁,也会在相应的结构中记录下来。当一个线程请求失败时,需要遍历一下这个数据结构检查是否有死锁产生
Volatile关键字作用
- 保证了不同线程对共有资源(公有变量)操作的可见性。当这个变量被修改时,这个操作对其它线程是可见的;
- 禁止进行指令的重排序,可以一定程度上保证有序性。
- 当某一线程修改了被该关键字修饰的变量时,会导致其它线程的工作内存中的缓存行无效,那么它们在读取该变量时就会重新从对应的主存地址中读取最新的值。
volatile
关键字不能保证操作的原子性。
HashMap在多线程环境下使用需要注意什么?为什么?
- 并发操作
HashMap
,是有可能带来死循环以及数据丢失问题的,还有可能产生脏读。 - 并发操作下,往
HashMap
进行put
操作达到临界值时,是需要扩充HashMap
的容量的,而扩充则会进行rehash()
和resize()
方法,多线程情况下可能会产生执行多个rehash()
和resize()
方法,这样就可能造成环行链表。此时若执行get()
时,可能就产生死循环了。
守护线程及其作用
- 守护线程又被称为服务进程、精灵线程、后台线程,是指在程序运行是在后台提供一种通用的线程,这种线程并不属于程序不可或缺的部分。 通俗点讲,任何一个守护线程都是整个
JVM
中所有非守护线程,也就是用户线程的保姆。 - 用户线程和守护线程几乎一样,唯一的不同之处就在于如果用户线程已经全部退出运行,只剩下守护线程存在了,
JVM
也就退出了。 - 当所有非守护线程结束时,没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了,程序也就终止了,同时会**“杀死”**所有守护线程。
- 也就是说,只要有任何非守护线程还在运行,程序就不会终止。
- 在
Java
语言中,守护线程一般具有较低的优先级,它并非只由JVM
内部提供,用户在编写程序时也可以自己设置守护线程,例如将一个用户线程设置为守护线程的方法就是在调用start()
方法启动线程之前调用对象的**setDaemon(true)
**方法,若将以上括号里的参数设置为false
,则表示的是用户进程模式。 - 当在一个守护线程中产生了其它线程,那么这些新产生的线程默认还是守护线程,用户线程也是如此。
- 垃圾回收器就是一个守护线程。
线程和进程
- 进程:是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,竞争计算机系统资源的基本单位。是资源(
CPU
、内存等)分配的基本单元,它是程序执行时的一个实例。 - 线程:是进程的一个执行单元,是进程内部调度的实体。比进程更小的独立运行的基本单位。线程是程序执行时的最小单元,它是进程的一个执行流,是
CPU
调度和分派的基本单位。**一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量。**线程由CPU
独立调度执行,在多CPU
环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。 - 一个程序至少一个进程,一个进程至少一个线程。两者均可并发执行。
- 两者的区别
- 地址空间:同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间。
- 资源拥有:同一进程内的线程共享本进程的资源如内存、
I/O
、CPU
等,但是进程之间的资源是独立的。 - 执行过程:每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
- 进程是资源分配的最小单位,线程是程序执行的最小单位。
- 线程执行开销小,但是不利于资源的管理和保护。 线程适合在
SMP
机器(双CPU
系统)上运行。 - 进程执行开销大,但是能够很好的进行资源管理和保护。 进程可以跨机器前移。
Threadlocal的实现原理
- 首先,在每个线程
Thread
内部有一个ThreadLocal.ThreadLocalMap
类型的成员变量threadLocals
,这个threadLocals
就是用来存储实际的变量副本的,键值为当前ThreadLocal
变量,value
为变量副本(即T
类型的变量)。 - 初始时,在
Thread
里面,threadLocals
为空,当通过ThreadLocal
变量调用get()
方法或者set()
方法,就会对Thread
类中的threadLocals
进行初始化,并且以当前ThreadLocal
变量为键值,以ThreadLocal
要保存的副本变量为value
,存到threadLocals
。 - 实际上通过
ThreadLocal
创建的副本是存储在每个线程自己的threadLocals
中的; threadLocals
的类型ThreadLocalMap
的键值为ThreadLocal
对象,因为每个线程中可有多个threadLocal
变量。- 在进行
get
之前,必须先set
,否则会报空指针异常;如果想在get
之前不需要调用set
就能正常访问的话,必须重写initialValue()
方法。
ConcurrentHashMap的实现原理
- 在
JDK1.8
以前,ConcurrentHashMap
采用锁分段策略,默认内部有16
个Segment
数组。数据结构是由一个Segment
数组和多个HashEntry
组成。Segment
数组的意义就是将一个大的table
分割成多个小的table
来进行加锁,也就是上面的提到的锁分离技术,而每一个Segment
元素存储的是**HashEntry
数组+链表**,这个和HashMap
的数据存储结构一样。Segment
继承了ReentrantLock
,所以它就是一种可重入锁ReentrantLock
。在ConcurrentHashMap
,一个Segment
就是一个子哈希表,Segment
里维护了一个HashEntry
数组,并发环境下,对于不同Segment
的数据进行操作是不用考虑锁竞争的。所以,对于同一个Segment
的操作才需考虑线程同步,不同的Segment
则无需考虑。 - 在
JDK1.8及之后
,ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组 + 链表 + 红黑树的数据结构来实现,并发控制采用Synchronized
和CAS
来操作,看起来就像是优化过且线程安全的HashMap
,虽然在JDK1.8
中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本。 - 总结
JDK1.8
的实现降低锁的粒度。JDK1.7
锁的粒度是基于Segment
的,包含多个HashEntry
,而JDK1.8
锁的粒度就是HashEntry(首节点)
。JDK1.8
版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized
来进行同步,所以不需要分段锁的概念,也就不需要Segment
这种数据结构了,由于粒度的降低,实现的复杂度也增加了。JDK1.8
使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档。
- 【扩展】
JDK1.8
为什么使用内置锁synchronized
来代替重入锁ReentrantLock
- 因为粒度降低了,在相对而言的低粒度加锁方式,
synchronized
并不比ReentrantLock
差,在粗粒度加锁中ReentrantLock
可通过Condition
来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition
的优势就没有了。 JVM
的开发团队从来都没有放弃synchronized
,而且基于JVM
的synchronized
优化空间更大,使用内嵌的关键字比使用API
更加自然。- 在大量的数据操作下,对于
JVM
的内存压力,基于API
的ReentrantLock
会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据。
- 因为粒度降低了,在相对而言的低粒度加锁方式,
sleep和wait区别
sleep()
方法属于Thread
类中的,而wait()
方法,则是属于Object
类的sleep()
方法导致了程序暂停执行指定的时间,让出CPU
给其它线程,但是它的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态。- 在调用
sleep()
方法的过程中,线程不会释放对象锁。 - 当调用
wait()
方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有持有此对象锁的线程调用notify()/notifyAll()
方法后,该线程才会进行获取锁从而进入运行状态。 sleep()
必须捕获异常,wait()
不需要捕获异常。
notify和notifyAll
- 锁池和等待池
- 锁池:假设线程
A
已经拥有了某个对象**(不是类)的锁,而其它线程想要调用这个对象的某个synchronized
方法(或者synchronized块)
,由于这些线程在进入对象的synchronized
方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A
拥有,所以这些线程就进入了该对象的锁池中。** - 等待池:假设一个线程A调用了某个对象的
wait()
方法,线程A
就会释放该对象的锁后,进入到了该对象的等待池中。
- 锁池:假设线程
- notify和notifyAll
- 如果线程调用了对象的
wait()
方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。 - 当有线程调用了对象的
notifyAll()
方法(唤醒所有wait
线程)或notify()
方法(只随机唤醒一个wait
线程),**被唤醒的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。**也就是说,调用了notify
后只有一个线程会由等待池进入锁池,而notifyAll
会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。 - 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用
wait()
方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了synchronized
代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。 - 所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池,
notifyAll
调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而notify
只会唤醒一个线程。 - 所以,
notify
可能会导致死锁,而notifyAll
则不会。
- 如果线程调用了对象的
两个线程如何串行执行
- 使用
synchronized
锁机制,保证两个线程串行化的执行。
public class ThreadSerialize {
public static void main(String[] args){
ThreadA threadA = new ThreadA();
ThreadB threadB = new ThreadB();
ThreadC threadC = new ThreadC();
threadA.setThreadC(threadC);
threadB.setThreadA(threadA);
threadC.setThreadB(threadB);
threadA.start();
threadB.start();
threadC.start();
while (true){
try {
Thread.currentThread().sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class ThreadA extends Thread{
private ThreadC threadC;
@Override
public void run() {
while (true){
synchronized (threadC){
synchronized (this){
System.out.println("I am ThreadA。。。");
this.notify();
}
try {
threadC.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public void setThreadC(ThreadC threadC) {
this.threadC = threadC;
}
}
class ThreadB extends Thread{
private ThreadA threadA;
@Override
public void run() {
while (true){
synchronized (threadA){
synchronized (this){
System.out.println("I am ThreadB。。。");
this.notify();
}
try {
threadA.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public void setThreadA(ThreadA threadA) {
this.threadA = threadA;
}
}
class ThreadC extends Thread{
private ThreadB threadB;
@Override
public void run() {
while (true){
synchronized (threadB){
synchronized (this){
System.out.println("I am ThreadC。。。");
this.notify();
}
try {
threadB.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public void setThreadB(ThreadB threadB) {
this.threadB = threadB;
}
}
- 调用
Thread
类中的join()
方法完成串行执行。
/**
* 有三个线程T1 T2 T3,如何保证他们按顺序执行-转载
* 在T2的run中,调用t1.join,让t1执行完成后再让T2执行
* 在T3的run中,调用t2.join,让t2执行完成后再让T3执行
*/
public class ThreadByOrder {
static Thread t1 = new Thread(() -> System.out.println("t1"));
static Thread t2 = new Thread(() -> {
try {
t1.join();
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("t2");
});
static Thread3 = new Thread(new Runnable() {
@Override
public void run() {
try {
t2.join();;
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.print("t3");
}
});
public static void main(String[] args) {
t1.start();
t2.start();
t3.start();
}
}
- 通过
ReentrankLock
显示加锁的方式,实现串行化
public class Demo_ReentrantLock {
public static void main(String[] args) {
Printer p = new Printer();
Thread t1 = new Thread(() -> {
try {
while (true) {
p.print1();
}
} catch (Exception e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
while (true) {
p.print2();
}
} catch (Exception e) {
e.printStackTrace();
}
});
Thread t3 = new Thread(() -> {
try {
while (true) {
p.print3();
}
} catch (Exception e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
t3.start();
}
}
class Printer {
private ReentrantLock lock = new ReentrantLock();
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
private Condition c3 = lock.newCondition();
private int flag = 1;
public void print1() throws Exception {
lock.lock();
if (flag != 1) c1.await(); // 当前线程等待
System.out.println("面对疾风吧!1");
flag = 2;
c2.signal(); // 唤醒c2
lock.unlock();
}
public void print2() throws Exception {
lock.lock();
if (flag != 2) c2.await(); // 当前线程等待
System.out.println("面对疾风吧!2");
flag = 3;
c3.signal();
lock.unlock();
}
public void print3() throws Exception {
lock.lock();
if (flag != 3) c3.await(); // 当前线程等待
System.out.println("面对疾风吧!3");
flag = 1;
c1.signal();
lock.unlock();
}
}
- 将线程加入到一个有序队列中,一次执行。
上下文切换是什么含义
CPU
给每个任务一定的服务时间,当时间片轮转的时候,需要把当前状态保存下来,同时加载下一个任务,这个过程叫做上下文切换。时间片轮转的方式,使得多个任务利用一个CPU
执行成为可能,但是保存现场和加载现场,也带来了一定的性能消耗。- 性能消耗点:
context switch
过高,会导致CPU
像个搬运工,频繁在寄存器和运行队列直接奔波 ,更多的时间花在了线程切换,而不是真正工作的线程上。直接的消耗包括**CPU
寄存器需要保存和加载,系统调度器的代码需要执行**。间接消耗在于多核cache
之间的共享数据。 - 上下文切换是存储和恢复
CPU
状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。
可以运行时kill掉一个线程吗
- 一般情况下如果一个程序等待超时或者长期不使用,将会导致资源浪费,为了避免这种浪费,我们需要定时杀死线程。但是
Java
已经弃用了显示杀死另一个线程的方法。- 线程内调用
destroy()
方法杀死线程(已弃用)。 - 线程外调用
destory()
方法杀死线程,但是需要加上Java
监控,获取线程id
,否则不知道该线程的执行状态。 - 建议
Thread.interrupt()
加上线程代码中的相应InterruptedException
处理。
- 线程内调用
Java锁
-
可重入锁
- 也叫递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
- 在
Java
环境下ReentrantLock
和synchronized
都是可重入锁。
-
互斥锁
- 指的是一次最多只能有一个线程持有的锁。如
Java
的Lock
。
- 指的是一次最多只能有一个线程持有的锁。如
-
自旋锁
- **自旋锁可以使线程在没有取得锁的时候,不被挂起,而转去执行一个空循环,(即所谓的自旋,就是自己执行空循环),若在若干个空循环后,线程如果可以获得锁,则继续执行。**若线程依然不能获得锁,才会被挂起。
- **使用自旋锁后,线程被挂起的几率相对减少,线程执行的连贯性相对加强。**因此,对于那些锁竞争不是很激烈,锁占用时间很短的并发线程,具有一定的积极意义,但对于锁竞争激烈,单线程锁占用很长时间的并发程序,自旋锁在自旋等待后,往往毅然无法获得对应的锁,不仅仅白白浪费了
CPU
时间,最终还是免不了被挂起的操作 ,反而浪费了系统的资源。
-
阻塞锁
- 让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。
JAVA
中,能够进入\退出、阻塞状态或包含阻塞锁的方法有 ,synchronized
关键字(其中的重量锁),ReentrantLock
,Object.wait()\notify()
。
-
悲观锁
- 悲观锁
(Pessimistic Lock)
,顾名思义就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block
直到它拿到锁。 - 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。独占锁是悲观锁的一种实现。
- 悲观锁
-
乐观锁
- 乐观锁
(Optimistic Lock)
,顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。 - 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于
write_condition
机制的其实都是提供的乐观锁。使用CAS
来保证这个操作的原子性。
- 乐观锁
-
公平锁与非公平锁
- 公平锁即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。
- 非公平锁即无法保证锁的获取是按照请求锁的顺序进行的。这样就可能导致某个或者一些线程永远获取不到锁。
- 在
Java
中,synchronized
就是非公平锁,它无法保证等待的线程获取锁的顺序。 - 对于
ReentrantLock
和ReentrantReadWriteLock
,它默认情况下是非公平锁,但是可以设置为公平锁。
Lock lock = new ReentrantLock(true);// 公平锁 Lock lock = new ReentrantLock(false);// 非公平锁, 默认
-
锁粗化
(Lock Coarsening)
- 锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。
public class StringBufferTest { StringBuffer stringBuffer = new StringBuffer(); public void append(){ stringBuffer.append("a"); stringBuffer.append("b"); stringBuffer.append("c"); } }
- 这里每次调用
stringBuffer.append()
方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append
方法时进行加锁,最后一次append
方法结束后进行解锁。
-
轮询锁和定时锁
- 由
tryLock
实现,与无条件获取锁模式相比,它们具有更完善的错误恢复机制。可避免死锁的发生。 boolean tryLock():
仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回true
。如果锁不可用,则此方法将立即返回值false
。
- 由