Java多线程面试题,我丝毫不慌
最后发布:2020-07-28 09:18:51 首次发布:2020-07-28 09:18:51
版权
一、什么是多线程
一、初识多线程
1.1介绍进程
讲到线程,又不得不提进程了~
进程我们估计是很了解的了,在windows下打开任务管理器,可以发现我们在操作系统上运行的程序都是进程:
进程的定义:
进程是程序的一次执行,进程是一个程序及其数据在处理机上顺序执行时所发生的活动,进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位
- 进程是系统进行资源分配和调度的独立单位。每一个进程都有它自己的内存空间和系统资源
1.2回到线程
那系统有了进程这么一个概念了,进程已经是可以进行资源分配和调度了,为什么还要线程呢?
为使程序能并发执行,系统必须进行以下的一系列操作:
- (1)创建进程,系统在创建一个进程时,必须为它分配其所必需的、除处理机以外的所有资源,如内存空间、I/O设备,以及建立相应的PCB;
- (2)撤消进程,系统在撤消进程时,又必须先对其所占有的资源执行回收操作,然后再撤消PCB;
- (3)进程切换,对进程进行上下文切换时,需要保留当前进程的CPU环境,设置新选中进程的CPU环境,因而须花费不少的处理机时间。
可以看到进程实现多处理机环境下的进程调度,分派,切换时,都需要花费较大的时间和空间开销
引入线程主要是**为了提高系统的执行效率,减少处理机的空转时间和调度切换的时间,以及便于系统管理。**使OS具有更好的并发性
- 简单来说:进程实现多处理非常耗费CPU的资源,而我们引入线程是作为调度和分派的基本单位(取代进程的部分基本功能**【调度】**)。
那么线程在哪呢??举个例子:
也就是说:在同一个进程内又可以执行多个任务,而这每一个任务我就可以看出是一个线程。
1.3进程与线程
于是我们可以总结出:
- 进程作为资源分配的基本单位
- 线程作为资源调度的基本单位,是程序的执行单元,执行路径(单线程:一条执行路径,多线程:多条执行路径)。是程序使用CPU的最基本单位。
线程有3个基本状态:
线程有5种基本操作:
线程的属性:
- 1)轻型实体;
- 2)独立调度和分派的基本单位;
- 3)可并发执行;
- 4)共享进程资源。
线程有两个基本类型:
-
- 用户级线程:管理过程全部由用户程序完成,操作系统内核心只对进程进行管理。
-
- 系统级线程(核心级线程):由操作系统内核进行管理。操作系统内核给应用程序提供相应的系统调用和应用程序接口API,以使用户程序可以创建、执行以及撤消线程。
值得注意的是:多线程的存在,不是提高程序的执行速度。其实是为了提高应用程序的使用率,程序的执行其实都是在抢CPU的资源,CPU的执行权。多个进程是在抢这个资源,而其中的某一个进程如果执行路径比较多,就会有更高的几率抢到CPU的执行权
1.4并行与并发
并行:
- 并行性是指同一时刻内发生两个或多个事件。
- 并行是在不同实体上的多个事件
并发:
- 并发性是指同一时间间隔内发生两个或多个事件。
- 并发是在同一实体上的多个事件
由此可见:并行是针对进程的,并发是针对线程的。
1.5Java实现多线程
上面说了一大堆基础,理解完的话。我们回到Java中,看看Java是如何实现多线程的~
Java实现多线程是使用Thread这个类的,我们来看看Thread类的顶部注释:
通过上面的顶部注释我们就可以发现,创建多线程有两种方法:
- 继承Thread,重写run方法
- 实现Runnable接口,重写run方法
- 实现Callable接口,重写run方法
1.5.1继承Thread,重写run方法
创建一个类,继承Thread,重写run方法
public class MyThread extends Thread {
@Override
public void run() {
for (int x = 0; x < 200; x++) {
System.out.println(x);
}
}
}
我们调用一下测试看看:
public class MyThreadDemo {
public static void main(String[] args) {
MyThread my1 = new MyThread();
MyThread my2 = new MyThread();
my1.start();
my2.start();
}
}
1.5.2实现Runnable接口,重写run方法
实现Runnable接口,重写run方法
public class MyRunnable implements Runnable {
@Override
public void run() {
for (int x = 0; x < 100; x++) {
System.out.println(x);
}
}
}
我们调用一下测试看看:
public class MyRunnableDemo {
public static void main(String[] args) {
MyRunnable my = new MyRunnable();
Thread t1 = new Thread(my);
Thread t2 = new Thread(my);
t1.start();
t2.start();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
结果还是跟上面是一样的,这里我就不贴图了~~~
1.6Java实现多线程需要注意的细节
不要将run()
和start()
搞混了~
run()和start()方法区别:
run()
:仅仅是封装被线程执行的代码,直接调用是普通方法
start()
:首先启动了线程,然后再由jvm去调用该线程的run()方法。
jvm虚拟机的启动是单线程的还是多线程的?
- 是多线程的。不仅仅是启动main线程,还至少会启动垃圾回收线程的,不然谁帮你回收不用的内存~
那么,既然有两种方式实现多线程,我们使用哪一种???
一般我们使用实现Runnable接口
- 可以避免java中的单继承的限制
- 应该将并发运行任务和运行机制解耦,因此我们选择实现Runnable接口这种方式!
二、Thread类解析
一、Thread线程类API
声明本文使用的是JDK1.8
实现多线程从本质上都是由Thread类来进行操作的~我们来看看Thread类一些重要的知识点。Thread这个类很大,不可能整个把它看下来,只能看一些常见的、重要的方法。
1.1设置线程名
我们在使用多线程的时候,想要查看线程名是很简单的,调用Thread.currentThread().getName()
即可。
如果没有做什么的设置,我们会发现线程的名字是这样子的:主线程叫做main,其他线程是Thread-x
下面我就带着大家来看看它是怎么命名的:
nextThreadNum()
的方法实现是这样的:
基于这么一个变量–>线程初始化的数量
点进去看到init方法就可以确定了:
看到这里,如果我们想要为线程起个名字,那也是很简单的。Thread给我们提供了构造方法!
下面我们来测试一下:
public class MyThread implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
测试:
public class MyThreadDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread();
Thread thread1 = new Thread(myThread, "关注公众号Java3y");
Thread thread2 = new Thread(myThread, "qq群:742919422");
thread1.start();
thread2.start();
System.out.println(Thread.currentThread().getName());
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
结果:
当然了,我们还可以通过setName(String name)
的方法来改掉线程的名字的。我们来看看方法实现;
检查是否有权限修改:
至于threadStatus这个状态属性,貌似没发现他会在哪里修改:
1.2守护线程
守护线程是为其他线程服务的
守护线程有一个特点:
- 当别的用户线程执行完了,虚拟机就会退出,守护线程也就会被停止掉了。
- 也就是说:守护线程作为一个服务线程,没有服务对象就没有必要继续运行了
使用线程的时候要注意的地方
- 在线程启动前设置为守护线程,方法是
setDaemon(boolean on)
- 使用守护线程不要访问共享资源(数据库、文件等),因为它可能会在任何时候就挂掉了。
- 守护线程中产生的新线程也是守护线程
测试一波:
public class MyThreadDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread();
Thread thread1 = new Thread(myThread, "关注公众号Java3y");
Thread thread2 = new Thread(myThread, "qq群:742919422");
thread2.setDaemon(true);
thread1.start();
thread2.start();
System.out.println(Thread.currentThread().getName());
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
上面的代码运行多次可以出现(电脑性能足够好的同学可能测试不出来):线程1和主线程执行完了,我们的守护线程就不执行了~
原理:这也就为什么我们要在启动之前设置守护线程了。
1.3优先级线程
线程优先级高仅仅表示线程获取的CPU时间片的几率高,但这不是一个确定的因素!
线程的优先级是高度依赖于操作系统的,Windows和Linux就有所区别(Linux下优先级可能就被忽略了)~
可以看到的是,Java提供的优先级默认是5,最低是1,最高是10:
实现:
setPriority0
是一个本地(navite)的方法:
private native void setPriority0(int newPriority);
1.4线程生命周期
在上一篇介绍的时候其实也提过了线程的线程有3个基本状态:执行、就绪、阻塞
在Java中我们就有了这个图,Thread上很多的方法都是用来切换线程的状态的,这一部分是重点!
其实上面这个图是不够完整的,省略掉了一些东西。后面在讲解的线程状态的时候我会重新画一个~
下面就来讲解与线程生命周期相关的方法~
1.4.1sleep方法
调用sleep方法会进入计时等待状态,等时间到了,进入的是就绪状态而并非是运行状态!
于是乎,我们的图就可以补充成这样:
1.4.2yield方法
调用yield方法会先让别的线程执行,但是不确保真正让出
于是乎,我们的图就可以补充成这样:
1.4.3join方法
调用join方法,会等待该线程执行完毕后才执行别的线程~
我们进去看看具体的实现:
wait方法是在Object上定义的,它是native本地方法,所以就看不了了:
wait方法实际上它也是**计时等待(如果带时间参数)**的一种!,于是我们可以补充我们的图:
1.4.3interrupt方法
线程中断在之前的版本有stop方法,但是被设置过时了。现在已经没有强制线程终止的方法了!
由于stop方法可以让一个线程A终止掉另一个线程B
- 被终止的线程B会立即释放锁,这可能会让对象处于不一致的状态。
- 线程A也不知道线程B什么时候能够被终止掉,万一线程B还处理运行计算阶段,线程A调用stop方法将线程B终止,那就很无辜了~
总而言之,Stop方法太暴力了,不安全,所以被设置过时了。
我们一般使用的是interrupt来请求终止线程~
- 要注意的是:interrupt不会真正停止一个线程,它仅仅是给这个线程发了一个信号告诉它,它应该要结束了(明白这一点非常重要!)
- 也就是说:Java设计者实际上是想线程自己来终止,通过上面的信号,就可以判断处理什么业务了。
- 具体到底中断还是继续运行,应该由被通知的线程自己处理
Thread t1 = new Thread( new Runnable(){
public void run(){
while(!Thread.currentThread.isInterrupted()){
}
doSomething();
}
} ).start();
再次说明:调用interrupt()并不是要真正终止掉当前线程,仅仅是设置了一个中断标志。这个中断标志可以给我们用来判断什么时候该干什么活!什么时候中断由我们自己来决定,这样就可以安全地终止线程了!
我们来看看源码是怎么讲的吧:
再来看看刚才说抛出的异常是什么东东吧:
所以说:interrupt方法压根是不会对线程的状态造成影响的,它仅仅设置一个标志位罢了
interrupt线程中断还有另外两个方法(检查该线程是否被中断):
- 静态方法interrupted()–>会清除中断标志位
- 实例方法isInterrupted()–>不会清除中断标志位
上面还提到了,如果阻塞线程调用了interrupt()方法,那么会抛出异常,设置标志位为false,同时该线程会退出阻塞的。我们来测试一波:
public class Main {
public static void main(String[] args) {
Main main = new Main();
Thread t = new Thread(main.runnable);
System.out.println("This is main ");
t.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
System.out.println("In main");
e.printStackTrace();
}
t.interrupt();
}
Runnable runnable = () -> {
int i = 0;
try {
while (i < 1000) {
Thread.sleep(500);
System.out.println(i++);
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().isAlive());
System.out.println(Thread.currentThread().isInterrupted());
System.out.println("In Runnable");
e.printStackTrace();
}
};
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
结果:
接下来我们分析它的执行流程是怎么样的:
三、使用多线程需要注意的问题
首先来预览一下《Java并发编程实战》前4章的目录究竟在讲什么吧:
第1章 简介
- 1.1 并发简史
- 1.2 线程的优势
- 1.2.1 发挥多处理器的强大能力
- 1.2.2 建模的简单性
- 1.2.3 异步事件的简化处理
- 1.2.4 响应更灵敏的用户界面
- 1.3 线程带来的风险
- 1.3.1 安全性问题
- 1.3.2 活跃性问题
- 1.3.3 性能问题
- 1.4 线程无处不在
ps:这一部分我就不讲了,主要是引出我们接下来的知识点,有兴趣的同学可翻看原书~
第2章 线程安全性
- 2.1 什么是线程安全性
- 2.2 原子性
- 2.2.1 竞态条件
- 2.2.2 示例:延迟初始化中的竞态条件
- 2.2.3 复合操作
- 2.3 加锁机制
- 2.3.1 内置锁
- 2.3.2 重入
- 2.4 用锁来保护状态
- 2.5 活跃性与性能
第3章 对象的共享
- 3.1 可见性
- 3.1.1 失效数据
- 3.1.2 非原子的64位操作
- 3.1.3 加锁与可见性
- 3.1.4 Volatile变量
- 3.2 发布与逸出
- 3.3 线程封闭
- 3.3.1 Ad-hoc线程封闭
- 3.3.2 栈封闭
- 3.3.3 ThreadLocal类
- 3.4 不变性
- 3.4.1 Final域
- 3.4.2 示例:使用Volatile类型来发布不可变对象
- 3.5 安全发布
- 3.5.1 不正确的发布:正确的对象被破坏
- 3.5.2 不可变对象与初始化安全性
- 3.5.3 安全发布的常用模式
- 3.5.4 事实不可变对象
- 3.5.5 可变对象
- 3.5.6 安全地共享对象
第4章 对象的组合
- 4.1 设计线程安全的类
- 4.1.1 收集同步需求
- 4.1.2 依赖状态的操作
- 4.1.3 状态的所有权
- 4.2 实例封闭
- 4.2.1 Java监视器模式
- 4.2.2 示例:车辆追踪
- 4.3 线程安全性的委托
- 4.3.1 示例:基于委托的车辆追踪器
- 4.3.2 独立的状态变量
- 4.3.3 当委托失效时
- 4.3.4 发布底层的状态变量
- 4.3.5 示例:发布状态的车辆追踪器
- 4.4 在现有的线程安全类中添加功能
- 4.4.1 客户端加锁机制
- 4.4.2 组合
- 4.5 将同步策略文档化
那么接下来我们就开始吧~
一、使用多线程遇到的问题
1.1线程安全问题
在前面的文章中已经讲解了线程【多线程三分钟就可以入个门了!】,多线程主要是为了提高我们应用程序的使用率。但同时,这会给我们带来很多安全问题!
如果我们在单线程中以“顺序”(串行–>独占)的方式执行代码是没有任何问题的。但是到了多线程的环境下(并行),如果没有设计和控制得好,就会给我们带来很多意想不到的状况,也就是线程安全性问题
因为在多线程的环境下,线程是交替执行的,一般他们会使用多个线程执行相同的代码。如果在此相同的代码里边有着共享的变量,或者一些组合操作,我们想要的正确结果就很容易出现了问题
简单举个例子:
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
++count;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
但是在多线程环境下跑起来,它的count值计算就不对了!
首先,它共享了count这个变量,其次来说++count;
这是一个组合的操作(注意,它并非是原子性)
++count
实际上的操作是这样子的:
- 读取count值
- 将值+1
- 将计算结果写入count
于是多线程执行的时候很可能就会有这样的情况:
- 当线程A读取到count的值是8的时候,同时线程B也进去这个方法上了,也是读取到count的值为8
- 它俩都对值进行加1
- 将计算结果写入到count上。但是,写入到count上的结果是9
- 也就是说:两个线程进来了,但是正确的结果是应该返回10,而它返回了9,这是不正常的!
如果说:当多个线程访问某个类的时候,这个类始终能表现出正确的行为,那么这个类就是线程安全的!
有个原则:能使用JDK提供的线程安全机制,就使用JDK的。
当然了,此部分其实是我们学习多线程最重要的环节,这里我就不详细说了。这里只是一个总览,这些知识点在后面的学习中都会遇到~~~
1.3性能问题
使用多线程我们的目的就是为了提高应用程序的使用率,但是如果多线程的代码没有好好设计的话,那未必会提高效率。反而降低了效率,甚至会造成死锁!
就比如说我们的Servlet,一个Servlet对象可以处理多个请求的,Servlet显然是一个天然支持多线程的。
又以下面的例子来说吧:
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
++count;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
从上面我们已经说了,上面这个类是线程不安全的。最简单的方式:如果我们在service方法上加上JDK为我们提供的内置锁synchronized,那么我们就可以实现线程安全了。
public class UnsafeCountingServlet extends GenericServlet implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void synchronized service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
++count;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
虽然实现了线程安全了,但是这会带来很严重的性能问题:
- 每个请求都得等待上一个请求的service方法处理了以后才可以完成对应的操作
这就导致了:我们完成一个小小的功能,使用了多线程的目的是想要提高效率,但现在没有把握得当,却带来严重的性能问题!
在使用多线程的时候:更严重的时候还有死锁(程序就卡住不动了)。
这些都是我们接下来要学习的地方:学习使用哪种同步机制来实现线程安全,并且性能是提高了而不是降低了~
二、对象的发布与逸出
书上是这样定义发布和逸出的:
发布(publish) 使对象能够在当前作用域之外的代码中使用
逸出(escape) 当某个不应该发布的对象被发布了
常见逸出的有下面几种方式:
- 静态域逸出
- public修饰的get方法
- 方法参数传递
- 隐式的this
静态域逸出:
public修饰get方法:
方法参数传递我就不再演示了,因为把对象传递过去给另外的方法,已经是逸出了~
下面来看看该书给出this逸出的例子:
逸出就是本不应该发布对象的地方,把对象发布了。导致我们的数据泄露出去了,这就造成了一个安全隐患!理解起来是不是简单了一丢丢?
2.1安全发布对象
上面谈到了好几种逸出的情况,我们接下来来谈谈如何安全发布对象。
安全发布对象有几种常见的方式:
- 在静态域中直接初始化 :
public static Person = new Person()
;
- 静态初始化由JVM在类的初始化阶段就执行了,JVM内部存在着同步机制,致使这种方式我们可以安全发布对象
- 对应的引用保存到volatile或者AtomicReferance引用中
- 由final修饰
- 该对象是不可变的,那么线程就一定是安全的,所以是安全发布~
- 由锁来保护
- 发布和使用的时候都需要加锁,这样才保证能够该对象不会逸出
三、解决多线程遇到的问题
从上面我们就可以看到,使用多线程会把我们的系统搞得挺复杂的。是需要我们去处理很多事情,为了防止多线程给我们带来的安全和性能的问题~
下面就来简单总结一下我们需要哪些知识点来解决多线程遇到的问题。
3.1简述解决线程安全性的办法
使用多线程就一定要保证我们的线程是安全的,这是最重要的地方!
在Java中,我们一般会有下面这么几种办法来实现线程安全问题:
- 无状态(没有共享变量)
- 使用final使该引用变量不可变(如果该对象引用也引用了其他的对象,那么无论是发布或者使用时都需要加锁)
- 加锁(内置锁,显示Lock锁)
- 使用JDK为我们提供的类来实现线程安全(此部分的类就很多了)
- 原子性(就比如上面的
count++
操作,可以使用AtomicLong来实现原子性,那么在增加的时候就不会出差错了!)
- 容器(ConcurrentHashMap等等…)
- …
- …等等
3.2原子性和可见性
何为原子性?何为可见性?当初我在ConcurrentHashMap基于JDK1.8源码剖析中已经简单说了一下了。不了解的同学可以进去看看。
3.2.1原子性
在多线程中很多时候都是因为某个操作不是原子性的,使数据混乱出错。如果操作的数据是原子性的,那么就可以很大程度上避免了线程安全问题了!
count++
,先读取,后自增,再赋值。如果该操作是原子性的,那么就可以说线程安全了(因为没有中间的三部环节,一步到位【原子性】~
原子性就是执行某一个操作是不可分割的,
- 比如上面所说的
count++
操作,它就不是一个原子性的操作,它是分成了三个步骤的来实现这个操作的~
- JDK中有atomic包提供给我们实现原子性操作
也有人将其做成了表格来分类,我们来看看:
3.2.2可见性
对于可见性,Java提供了一个关键字:volatile给我们使用~
- 我们可以简单认为:volatile是一种轻量级的同步机制
volatile经典总结:volatile仅仅用来保证该变量对所有线程的可见性,但不保证原子性
我们将其拆开来解释一下:
- 保证该变量对所有线程的可见性
- 在多线程的环境下:当这个变量修改时,所有的线程都会知道该变量被修改了,也就是所谓的“可见性”
- 不保证原子性
- 修改变量(赋值)实质上是在JVM中分了好几步,而在这几步内(从装载变量到修改),它是不安全的。
使用了volatile修饰的变量保证了三点:
- 一旦你完成写入,任何访问这个字段的线程将会得到最新的值
- 在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
- volatile可以防止重排序(重排序指的就是:程序执行的时候,CPU、编译器可能会对执行顺序做一些调整,导致执行的顺序并不是从上往下的。从而出现了一些意想不到的效果)。而如果声明了volatile,那么CPU、编译器就会知道这个变量是共享的,不会被缓存在寄存器或者其他不可见的地方。
一般来说,volatile大多用于标志位上(判断操作),满足下面的条件才应该使用volatile修饰变量:
- 修改变量时不依赖变量的当前值(因为volatile是不保证原子性的)
- 该变量不会纳入到不变性条件中(该变量是可变的)
- 在访问变量的时候不需要加锁(加锁就没必要使用volatile这种轻量级同步机制了)
3.3线程封闭
在多线程的环境下,只要我们不使用成员变量(不共享数据),那么就不会出现线程安全的问题了。
就用我们熟悉的Servlet来举例子,写了那么多的Servlet,你见过我们说要加锁吗??我们所有的数据都是在方法(栈封闭)上操作的,每个线程都拥有自己的变量,互不干扰!
在方法上操作,只要我们保证不要在栈(方法)上发布对象(每个变量的作用域仅仅停留在当前的方法上),那么我们的线程就是安全的
在线程封闭上还有另一种方法,就是我之前写过的:ThreadLocal就是这么简单
使用这个类的API就可以保证每个线程自己独占一个变量。(详情去读上面的文章即可)~
3.4不变性
不可变对象一定线程安全的。
上面我们共享的变量都是可变的,正由于是可变的才会出现线程安全问题。如果该状态是不可变的,那么随便多个线程访问都是没有问题的!
Java提供了final修饰符给我们使用,final的身影我们可能就见得比较多了,但值得说明的是:
- final仅仅是不能修改该变量的引用,但是引用里边的数据是可以改的!
就好像下面这个HashMap,用final修饰了。但是它仅仅保证了该对象引用hashMap变量
所指向是不可变的,但是hashMap内部的数据是可变的,也就是说:可以add,remove等等操作到集合中~~~
- 因此,仅仅只能够说明hashMap是一个不可变的对象引用
final HashMap<Person> hashMap = new HashMap<>();
不可变的对象引用在使用的时候还是需要加锁的
- 或者把Person也设计成是一个线程安全的类~
- 因为内部的状态是可变的,不加锁或者Person不是线程安全类,操作都是有危险的!
要想将对象设计成不可变对象,那么要满足下面三个条件:
- 对象创建后状态就不能修改
- 对象所有的域都是final修饰的
- 对象是正确创建的(没有this引用逸出)
String在我们学习的过程中我们就知道它是一个不可变对象,但是它没有遵循第二点(对象所有的域都是final修饰的),因为JVM在内部做了优化的。但是我们如果是要自己设计不可变对象,是需要满足三个条件的。
3.5线程安全性委托
很多时候我们要实现线程安全未必就需要自己加锁,自己来设计。
我们可以使用JDK给我们提供的对象来完成线程安全的设计:
非常多的"工具类"供我们使用,这些在往后的学习中都会有所介绍的~~这里就不介绍了
四、多线程需要注意的事 -总结
正确使用多线程能够提高我们应用程序的效率,同时给我们会带来非常多的问题,这些都是我们在使用多线程之前需要注意的地方。
无论是不变性、可见性、原子性、线程封闭、委托这些都是实现线程安全的一种手段。要合理地使用这些手段,我们的程序才可以更加健壮!
四、synchronized锁和lock锁
本文章主要讲的是Java多线程加锁机制,有两种:
不得不唠叨几句:
- 在《Java核心技术卷 一》是先讲比较难的显式Lock,而再讲的是比较简单的Synchronized
- 而《Java并发编程实战》在前4章零散地讲解了Synchronized,将显式Lock放到了13章
其实都比较坑,如果能先系统讲了Synchronized锁机制,接着讲显式Lock锁机制,那就很容易理解了。也不需要跨那么多章节。
那么接下来我们就开始吧~
一、synchronized锁
1.1synchronized锁是什么?
synchronized是Java的一个关键字,它能够将代码块(方法)锁起来
- 它使用起来是非常简单的,只要在代码块(方法)添加关键字synchronized,即可以实现同步的功能~
public synchronized void test() {
}
synchronized是一种互斥锁
synchronized是一种内置锁/监视器锁
- Java中每个对象都有一个内置锁(监视器,也可以理解成锁标记),而synchronized就是使用**对象的内置锁(监视器)**来将代码块(方法)锁定的! (锁的是对象,但我们同步的是方法/代码块)
1.2synchronized用处是什么?
- synchronized保证了线程的原子性。(被保护的代码块是一次被执行的,没有任何线程会同时访问)
- synchronized还保证了可见性。(当执行完synchronized之后,修改后的变量对其他的线程是可见的)
Java中的synchronized,通过使用内置锁,来实现对变量的同步操作,进而实现了对变量操作的原子性和其他线程对变量的可见性,从而确保了并发情况下的线程安全。
1.3synchronized的原理
我们首先来看一段synchronized修饰方法和代码块的代码:
public class Main {
public synchronized void test1(){
}
public void test2(){
synchronized (this){
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
来反编译看一下:
同步代码块:
- monitorenter和monitorexit指令实现的
同步方法(在这看不出来需要看JVM底层实现)
- 方法修饰符上的ACC_SYNCHRONIZED实现。
synchronized底层是是通过monitor对象,对象有自己的对象头,存储了很多信息,其中一个信息标示是被哪个线程持有。
1.4synchronized如何使用
synchronized一般我们用来修饰三种东西:
1.4.1修饰普通方法:
用的锁是Java3y对象(内置锁)
public class Java3y {
public synchronized void test() {
}
}
1.4.2修饰代码块:
用的锁是Java3y对象(内置锁)—>this
public class Java3y {
public void test() {
synchronized (this){
}
}
}
当然了,我们使用synchronized修饰代码块时未必使用this,还可以使用其他的对象(随便一个对象都有一个内置锁)
所以,我们可以这样干:
public class Java3y {
private Object object = new Object();
public void test() {
synchronized (object){
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
上面那种方式(随便使用一个对象作为锁)在书上称之为–>客户端锁,这是不建议使用的。
书上想要实现的功能是:给ArrayList添加一个putIfAbsent()
,这需要是线程安全的。
假定直接添加synchronized是不可行的
使用客户端锁,会将当前的实现与原本的list耦合了:
书上给出的办法是使用组合的方式(也就是装饰器模式)
1.4.3修饰静态方法
获取到的是类锁(类的字节码文件对象):Java3y.class
public class Java3y {
public static synchronized void test() {
}
}
1.4.4类锁与对象锁
synchronized修饰静态方法获取的是类锁(类的字节码文件对象),synchronized修饰普通方法或代码块获取的是对象锁。
- 它俩是不冲突的,也就是说:获取了类锁的线程和获取了对象锁的线程是不冲突的!
public class SynchoronizedDemo {
public synchronized void function() throws InterruptedException {
for (int i = 0; i <3; i++) {
Thread.sleep(1000);
System.out.println("function running...");
}
}
public static synchronized void staticFunction()
throws InterruptedException {
for (int i = 0; i < 3; i++) {
Thread.sleep(1000);
System.out.println("Static function running...");
}
}
public static void main(String[] args) {
final SynchoronizedDemo demo = new SynchoronizedDemo();
Thread t1 = new Thread(() -> {
try {
staticFunction();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
demo.function();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
结果证明:类锁和对象锁是不会冲突的!
1.5重入锁
我们来看下面的代码:
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 当线程A进入到LoggingWidget的
doSomething()
方法时,此时拿到了LoggingWidget实例对象的锁。
- 随后在方法上又调用了父类Widget的
doSomething()
方法,它又是被synchronized修饰。
- 那现在我们LoggingWidget实例对象的锁还没有释放,进入父类Widget的
doSomething()
方法还需要一把锁吗?
不需要的!
因为锁的持有者是“线程”,而不是“调用”。线程A已经是有了LoggingWidget实例对象的锁了,当再需要的时候可以继续**“开锁”**进去的!
这就是内置锁的可重入性。记住,持有锁的是线程。
1.6释放锁的时机
- 当方法(代码块)执行完毕后会自动释放锁,不需要做任何的操作。
- 当一个线程执行的代码出现异常时,其所持有的锁会自动释放。
二、Lock显式锁
2.1Lock显式锁简单介绍
Lock显式锁是JDK1.5之后才有的,之前我们都是使用Synchronized锁来使线程安全的~
Lock显式锁是一个接口,我们来看看:
随便翻译一下他的顶部注释,看看是干嘛用的:
可以简单概括一下:
- Lock方式来获取锁支持中断、超时不获取、是非阻塞的
- 提高了语义化,哪里加锁,哪里解锁都得写出来
- Lock显式锁可以给我们带来很好的灵活性,但同时我们必须手动释放锁
- 支持Condition条件对象
- 允许多个读线程同时访问共享资源
2.2synchronized锁和Lock锁使用哪个
前面说了,Lock显式锁给我们的程序带来了很多的灵活性,很多特性都是Synchronized锁没有的。那Synchronized锁有没有存在的必要??
必须是有的!!Lock锁在刚出来的时候很多性能方面都比Synchronized锁要好,但是从JDK1.6开始Synchronized锁就做了各种的优化(毕竟亲儿子,牛逼)
- 优化操作:适应自旋锁,锁消除,锁粗化,轻量级锁,偏向锁。
所以,到现在Lock锁和Synchronized锁的性能其实差别不是很大!而Synchronized锁用起来又特别简单。Lock锁还得顾忌到它的特性,要手动释放锁才行(如果忘了释放,这就是一个隐患)
所以说,我们绝大部分时候还是会使用Synchronized锁,用到了Lock锁提及的特性,带来的灵活性才会考虑使用Lock显式锁~
2.3公平锁
公平锁理解起来非常简单:
非公平锁就是:
Lock和synchronize都是默认使用非公平锁的。如果不是必要的情况下,不要使用公平锁
三、Java锁简单总结
本文讲了synchronized内置锁和简单描述了一下Lock显式锁,总得来说:
- synchronized好用,简单,性能不差
- 没有使用到Lock显式锁的特性就不要使用Lock锁了。
五、AQS
本来我是打算在这章节中写Lock的子类实现的,但看到了AQS的这么一个概念,可以说Lock的子类实现都是基于AQS的。
AQS我在面试题中也见过他的身影,但一直不知道是什么东西。所以本篇我就来讲讲AQS这个玩意吧,至少知道它的概念是什么,对吧~
那么接下来我们就开始吧~
一、AQS是什么?
首先我们来普及一下juc是什么:juc其实就是包的缩写(java.util.concurrnt)
- 不要被人家唬到了,以为juc是什么一个牛逼的东西。其实指的是包而已~
我们可以发现lock包下有三个抽象的类:
- AbstractOwnableSynchronizer
- AbstractQueuedLongSynchronizer
- AbstractQueuedSynchronizer
通常地:AbstractQueuedSynchronizer简称为AQS
我们Lock之类的两个常见的锁都是基于它来实现的:
那么我们来看看AbstractQueuedSynchronizer到底是什么,看一个类是干什么的最快途径就是看它的顶部注释
通读了一遍,可以总结出以下比较关键的信息:
- AQS其实就是一个可以给我们实现锁的框架
- 内部实现的关键是:先进先出的队列、state状态
- 定义了内部类ConditionObject
- 拥有两种线程模式
- 在LOCK包中的相关锁(常用的有ReentrantLock、 ReadWriteLock)都是基于AQS来构建
- 一般我们叫AQS为同步器
二、简单看看AQS
上面也提到了AQS里边最重要的是状态和队列,我们接下来就看看其源码是怎么样的…
2.1同步状态
使用volatile修饰实现线程可见性:
修改state状态值时使用CAS算法来实现:
2.2先进先出队列
这个队列被称为:CLH队列(三个名字组成),是一个双向队列
看看它队列源码的组成:
static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
final boolean isShared() {
return nextWaiter == SHARED;
}
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() {
}
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
2.3acquire方法
获取独占锁的过程就是在acquire定义的,该方法用到了模板设计模式,由子类实现的~
过程:acquire(int)尝试获取资源,如果获取失败,将线程插入等待队列。插入等待队列后,acquire(int)并没有放弃获取资源,而是根据前置节点状态状态判断是否应该继续获取资源,如果前置节点是头结点,继续尝试获取资源,如果前置节点是SIGNAL状态,就中断当前线程,否则继续尝试获取资源。直到当前线程被park()或者获取到资源,acquire(int)结束。
来源:
- https://blog.csdn.net/panweiwei1994/article/details/78769703
2.4release方法
释放独占锁的过程就是在acquire定义的,该方法也用到了模板设计模式,由子类实现的~
过程:首先调用子类的tryRelease()方法释放锁,然后唤醒后继节点,在唤醒的过程中,需要判断后继节点是否满足情况,如果后继节点不为且不是作废状态,则唤醒这个后继节点,否则从tail节点向前寻找合适的节点,如果找到,则唤醒.
来源:
- https://zhuanlan.zhihu.com/p/27134110
六、ReentrantLock和ReentrantReadWriteLock
上一篇已经将Lock锁的基础AQS简单地过了一遍了,因此本篇主要是讲解Lock锁主要的两个子类:
- ReentrantLock
- ReentrantReadWriteLock
那么接下来我们就开始吧~
一、ReentrantLock锁
首先我们来看看ReentrantLock锁的顶部注释,来看看他的相关特性呗:
来总结一下要点吧:
- 比synchronized更有伸缩性(灵活)
- 支持公平锁(是相对公平的)
- 使用时最标准用法是在try之前调用lock方法,在finally代码块释放锁
class X {
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
} finally {
lock.unlock()
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
1.1内部类
首先我们可以看到有三个内部类:
这些内部类都是AQS的子类,这就印证了我们之前所说的:AQS是ReentrantLock的基础,AQS是构建锁、同步器的框架
- 可以很清晰的看到,我们的ReentrantLock锁是支持公平锁和非公平锁的~
1.2构造方法
1.3非公平lock方法
尝试获取锁,获取失败的话就调用AQS的acquire(1)
方法
acquire(1)
方法我们在AQS时简单看过,其中tryAcquire()
是子类来实现的
我们去看看tryAcquire()
:
1.4公平lock方法
公平的lock方法其实就多了一个状态条件:
这个方法主要是判断当前线程是否位于CLH同步队列中的第一个。如果是则返回flase,否则返回true。
1.5unlock方法
unlock方法也是在AQS中定义的:
去看看tryRelease(arg)
是怎么实现的:
二、ReentrantReadWriteLock
我们知道synchronized内置锁和ReentrantLock都是互斥锁(一次只能有一个线程进入到临界区(被锁定的区域))
而ReentrantReadWriteLock是一个读写锁:
- 在读取数据的时候,可以多个线程同时进入到到临界区(被锁定的区域)
- 在写数据的时候,无论是读线程还是写线程都是互斥的
一般来说:我们大多数都是读取数据得多,修改数据得少。所以这个读写锁在这种场景下就很有用了!
读写锁有一个接口ReadWriteLock,定义的方法就两个:
我们还是来看看顶部注释说得啥吧:
其实大概也是说明了:在读的时候可以共享,在写的时候是互斥的
接下来我们还是来看看对应的实现类吧:
按照惯例也简单看看它的顶部注释:
于是我们可以总结出读写锁的一些要点了:
- 读锁不支持条件对象,写锁支持条件对象
- 读锁不能升级为写锁,写锁可以降级为读锁
- 读写锁也有公平和非公平模式
- 读锁支持多个读线程进入临界区,写锁是互斥的
2.1ReentrantReadWriteLock内部类
ReentrantReadWriteLock比ReentrantLock锁多了两个内部类(都是Lock实现)来维护读锁和写锁,但是主体还是使用Syn:
2.2读锁和写锁的状态表示
在ReentrantLock锁上使用的是state来表示同步状态(也可以表示重入的次数),而在ReentrantReadWriteLock是这样代表读写状态的:
2.3写锁的获取
主要还是调用syn的acquire(1)
:
进去看看实现:
2.4读锁获取
写锁的获取调用的是acquireShared(int arg)
方法:
内部调用的是:doAcquireShared(arg);
方法(实现也是在Syn的),我们来看看:
三、最后
这里就简单总结一下本文的内容吧:
- AQS是ReentrantReadWriteLock和ReentrantLock的基础,因为默认的实现都是在内部类Syn中,而Syn是继承AQS的~
- ReentrantReadWriteLock和ReentrantLock都支持公平和非公平模式,公平模式下会去看FIFO队列线程是否是在队头,而非公平模式下是没有的
- ReentrantReadWriteLock是一个读写锁,如果读的线程比写的线程要多很多的话,那可以考虑使用它。它使用state的变量高16位是读锁,低16位是写锁
- 写锁可以降级为读锁,读锁不能升级为写锁
- 写锁是互斥的,读锁是共享的。
七、线程池
一、线程池简介
线程池可以看做是线程的集合。在没有任务时线程处于空闲状态,当请求到来:线程池给这个请求分配一个空闲的线程,任务完成后回到线程池中等待下次任务**(而不是销毁)。这样就实现了线程的重用**。
我们来看看如果没有使用线程池的情况是这样的:
public class ThreadPerTaskWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = () -> handleRequest(connection);
new Thread(task).start();
}
}
private static void handleRequest(Socket connection) {
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
为每个请求都开一个新的线程虽然理论上是可以的,但是会有缺点:
- 线程生命周期的开销非常高。每个线程都有自己的生命周期,创建和销毁线程所花费的时间和资源可能比处理客户端的任务花费的时间和资源更多,并且还会有某些空闲线程也会占用资源。
- 程序的稳定性和健壮性会下降,每个请求开一个线程。如果受到了恶意攻击或者请求过多(内存不足),程序很容易就奔溃掉了。
所以说:我们的线程最好是交由线程池来管理,这样可以减少对线程生命周期的管理,一定程度上提高性能。
二、JDK提供的线程池API
JDK给我们提供了Excutor框架来使用线程池,它是线程池的基础。
- Executor提供了一种将**“任务提交”与“任务执行”**分离开来的机制(解耦)
下面我们来看看JDK线程池的总体api架构:
接下来我们把这些API都过一遍看看:
Executor接口:
ExcutorService接口:
AbstractExecutorService类:
ScheduledExecutorService接口:
ThreadPoolExecutor类:
ScheduledThreadPoolExecutor类:
2.1ForkJoinPool线程池
除了ScheduledThreadPoolExecutor和ThreadPoolExecutor类线程池以外,还有一个是JDK1.7新增的线程池:ForkJoinPool线程池
于是我们的类图就可以变得完整一些:
JDK1.7中新增的一个线程池,与ThreadPoolExecutor一样,同样继承了AbstractExecutorService。ForkJoinPool是Fork/Join框架的两大核心类之一。与其它类型的ExecutorService相比,其主要的不同在于采用了工作窃取算法(work-stealing):所有池中线程会尝试找到并执行已被提交到池中的或由其他线程创建的任务。这样很少有线程会处于空闲状态,非常高效。这使得能够有效地处理以下情景:大多数由任务产生大量子任务的情况;从外部客户端大量提交小任务到池中的情况。
来源:
- https://blog.csdn.net/panweiwei1994/article/details/78969238
2.2补充:Callable和Future
学到了线程池,我们可以很容易地发现:很多的API都有Callable和Future这么两个东西。
Future<?> submit(Runnable task)
<T> Future<T> submit(Callable<T> task)
其实它们也不是什么高深的东西~~~
我们可以简单认为:Callable就是Runnable的扩展。
- Runnable没有返回值,不能抛出受检查的异常,而Callable可以!
也就是说:当我们的任务需要返回值的时,我们就可以使用Callable!
Future一般我们认为是Callable的返回值,但他其实代表的是任务的生命周期(当然了,它是能获取得到Callable的返回值的)
简单来看一下他们的用法:
public class CallableDemo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Integer> f1 = pool.submit(new MyCallable(100));
Future<Integer> f2 = pool.submit(new MyCallable(200));
Integer i1 = f1.get();
Integer i2 = f2.get();
System.out.println(i1);
System.out.println(i2);
pool.shutdown();
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
Callable任务:
public class MyCallable implements Callable<Integer> {
private int number;
public MyCallable(int number) {
this.number = number;
}
@Override
public Integer call() throws Exception {
int sum = 0;
for (int x = 1; x <= number; x++) {
sum += x;
}
return sum;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
执行完任务之后可以获取得到任务返回的数据:
三、ThreadPoolExecutor详解
这是用得最多的线程池,所以本文会重点讲解它。
我们来看看顶部注释:
3.1内部状态
变量ctl定义为AtomicInteger,记录了“线程池中的任务数量”和“线程池的状态”两个信息。
线程的状态:
- RUNNING:线程池能够接受新任务,以及对新添加的任务进行处理。
- SHUTDOWN:线程池不可以接受新任务,但是可以对已添加的任务进行处理。
- STOP:线程池不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
- TIDYING:当所有的任务已终止,ctl记录的"任务数量"为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重载terminated()函数来实现。
- TERMINATED:线程池彻底终止的状态。
各个状态之间转换:
3.2已默认实现的池
下面我就列举三个比较常见的实现池:
- newFixedThreadPool
- newCachedThreadPool
- SingleThreadExecutor
如果读懂了上面对应的策略呀,线程数量这些,应该就不会太难看懂了。
3.2.1newFixedThreadPool
一个固定线程数的线程池,它将返回一个corePoolSize和maximumPoolSize相等的线程池。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
3.2.2newCachedThreadPool
非常有弹性的线程池,对于新的任务,如果此时线程池里没有空闲线程,线程池会毫不犹豫的创建一条新的线程去处理这个任务。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
3.2.3SingleThreadExecutor
使用单个worker线程的Executor
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
3.3构造方法
我们读完上面的默认实现池还有对应的属性,再回到构造方法看看
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 指定核心线程数量
- 指定最大线程数量
- 允许线程空闲时间
- 时间对象
- 阻塞队列
- 线程工厂
- 任务拒绝策略
再总结一遍这些参数的要点:
线程数量要点:
- 如果运行线程的数量少于核心线程数量,则创建新的线程处理请求
- 如果运行线程的数量大于核心线程数量,小于最大线程数量,则当队列满的时候才创建新的线程
- 如果核心线程数量等于最大线程数量,那么将创建固定大小的连接池
- 如果设置了最大线程数量为无穷,那么允许线程池适合任意的并发数量
线程空闲时间要点:
- 当前线程数大于核心线程数,如果空闲时间已经超过了,那该线程会销毁。
排队策略要点:
- 同步移交:不会放到队列中,而是等待线程执行它。如果当前线程没有执行,很可能会新开一个线程执行。
- 无界限策略:如果核心线程都在工作,该线程会放到队列中。所以线程数不会超过核心线程数
- 有界限策略:可以避免资源耗尽,但是一定程度上减低了吞吐量
当线程关闭或者线程数量满了和队列饱和了,就有拒绝任务的情况了:
拒绝任务策略:
- 直接抛出异常
- 使用调用者的线程来处理
- 直接丢掉这个任务
- 丢掉最老的任务
四、execute执行方法
execute执行方法分了三步,以注释的方式写在代码上了~
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
五、线程池关闭
ThreadPoolExecutor提供了shutdown()
和shutdownNow()
两个方法来关闭线程池
shutdown() :
shutdownNow():
区别:
- 调用shutdown()后,线程池状态立刻变为SHUTDOWN,而调用shutdownNow(),线程池状态立刻变为STOP。
- shutdown()等待任务执行完才中断线程,而shutdownNow()不等任务执行完就中断了线程。
八、死锁
一、死锁讲解
在Java中使用多线程,就会有可能导致死锁问题。死锁会让程序一直卡住,不再程序往下执行。我们只能通过中止并重启的方式来让程序重新执行。
- 这是我们非常不愿意看到的一种现象,我们要尽可能避免死锁的情况发生!
造成死锁的原因可以概括成三句话:
- 当前线程拥有其他线程需要的资源
- 当前线程等待其他线程已拥有的资源
- 都不放弃自己拥有的资源
1.1锁顺序死锁
首先我们来看一下最简单的死锁(锁顺序死锁)是怎么样发生的:
public class LeftRightDeadlock {
private final Object left = new Object();
private final Object right = new Object();
public void leftRight() {
synchronized (left) {
synchronized (right) {
doSomething();
}
}
}
public void rightLeft() {
synchronized (right) {
synchronized (left) {
doSomethingElse();
}
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
我们的线程是交错执行的,那么就很有可能出现以下的情况:
- 线程A调用
leftRight()
方法,得到left锁
- 同时线程B调用
rightLeft()
方法,得到right锁
- 线程A和线程B都继续执行,此时线程A需要right锁才能继续往下执行。此时线程B需要left锁才能继续往下执行。
- 但是:线程A的left锁并没有释放,线程B的right锁也没有释放。
- 所以他们都只能等待,而这种等待是无期限的–>永久等待–>死锁
1.2动态锁顺序死锁
我们看一下下面的例子,你认为会发生死锁吗?
public static void transferMoney(Account fromAccount,
Account toAccount,
DollarAmount amount)
throws InsufficientFundsException {
synchronized (fromAccount) {
synchronized (toAccount) {
if (fromAccount.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
} else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
上面的代码看起来是没有问题的:锁定两个账户来判断余额是否充足才进行转账!
但是,同样有可能会发生死锁:
- 如果两个线程同时调用
transferMoney()
- 线程A从X账户向Y账户转账
- 线程B从账户Y向账户X转账
- 那么就会发生死锁。
A:transferMoney(myAccount,yourAccount,10);
B:transferMoney(yourAccount,myAccount,20);
1.3协作对象之间发生死锁
我们来看一下下面的例子:
public class CooperatingDeadlock {
class Taxi {
@GuardedBy("this") private Point location, destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination))
dispatcher.notifyAvailable(this);
}
public synchronized Point getDestination() {
return destination;
}
public synchronized void setDestination(Point destination) {
this.destination = destination;
}
}
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public synchronized Image getImage() {
Image image = new Image();
for (Taxi t : taxis)
image.drawMarker(t.getLocation());
return image;
}
}
class Image {
public void drawMarker(Point p) {
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
上面的getImage()
和setLocation(Point location)
都需要获取两个锁的
这就是隐式获取两个锁(对象之间协作)…
这种方式也很容易就造成死锁…
二、避免死锁的方法
避免死锁可以概括成三种方法:
- 固定加锁的顺序(针对锁顺序死锁)
- 开放调用(针对对象之间协作造成的死锁)
- 使用定时锁–>
tryLock()
- 如果等待获取锁时间超时,则抛出异常而不是一直等待!
2.1固定锁顺序避免死锁
上面transferMoney()
发生死锁的原因是因为加锁顺序不一致而出现的~
- 正如书上所说的:如果所有线程以固定的顺序来获得锁,那么程序中就不会出现锁顺序死锁问题!
那么上面的例子我们就可以改造成这样子:
public class InduceLockOrder {
private static final Object tieLock = new Object();
public void transferMoney(final Account fromAcct,
final Account toAcct,
final DollarAmount amount)
throws InsufficientFundsException {
class Helper {
public void transfer() throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
}
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
if (fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
} else if (fromHash > toHash) {
synchronized (toAcct) {
synchronized (fromAcct) {
new Helper().transfer();
}
}
} else {
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
}
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
得到对应的hash值来固定加锁的顺序,这样我们就不会发生死锁的问题了!
2.2开放调用避免死锁
在协作对象之间发生死锁的例子中,主要是因为在调用某个方法时就需要持有锁,并且在方法内部也调用了其他带锁的方法!
- 如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用!
我们可以这样来改造:
- 同步代码块最好仅被用于保护那些涉及共享状态的操作!
class CooperatingNoDeadlock {
@ThreadSafe
class Taxi {
@GuardedBy("this") private Point location, destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
boolean reachedDestination;
synchronized (this) {
this.location = location;
reachedDestination = location.equals(destination);
}
if (reachedDestination)
dispatcher.notifyAvailable(this);
}
public synchronized Point getDestination() {
return destination;
}
public synchronized void setDestination(Point destination) {
this.destination = destination;
}
}
@ThreadSafe
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public Image getImage() {
Set<Taxi> copy;
synchronized (this) {
copy = new HashSet<Taxi>(taxis);
}
Image image = new Image();
for (Taxi t : copy)
image.drawMarker(t.getLocation());
return image;
}
}
class Image {
public void drawMarker(Point p) {
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
使用开放调用是非常好的一种方式,应该尽量使用它~
2.3使用定时锁
使用显式Lock锁,在获取锁时使用tryLock()
方法。当等待超过时限的时候,tryLock()
不会一直等待,而是返回错误信息。
使用tryLock()
能够有效避免死锁问题~~
2.4死锁检测
虽然造成死锁的原因是因为我们设计得不够好,但是可能写代码的时候不知道哪里发生了死锁。
JDK提供了两种方式来给我们检测:
- JconsoleJDK自带的图形化界面工具,使用JDK给我们的的工具JConsole
- Jstack是JDK自带的命令行工具,主要用于线程Dump分析。
具体可参考:
- https://www.cnblogs.com/flyingeagle/articles/6853167.html
三、死锁总结
发生死锁的原因主要由于:
- 线程之间交错执行
- 执行某方法时就需要持有锁,且不释放
- 解决:缩减同步代码块范围,最好仅操作共享变量时才加锁
- 永久等待
- 解决:使用
tryLock()
定时锁,超过时限则返回错误信息
九、线程常用的工具类
Java为我们提供了三个同步工具类:
- CountDownLatch(闭锁)
- CyclicBarrier(栅栏)
- Semaphore(信号量)
这几个工具类其实说白了就是为了能够更好控制线程之间的通讯问题~
一、CountDownLatch
1.1CountDownLatch简介
- A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.
简单来说:CountDownLatch是一个同步的辅助类,允许一个或多个线程一直等待,直到其它线程完成它们的操作。
它常用的API其实就两个:await()
和countDown()
使用说明:
- count初始化CountDownLatch,然后需要等待的线程调用await方法。await方法会一直受阻塞直到count=0。而其它线程完成自己的操作后,调用
countDown()
使计数器count减1。当count减到0时,所有在等待的线程均会被释放
- 说白了就是通过count变量来控制等待,如果count值为0了(其他线程的任务都完成了),那就可以继续执行。
1.2CountDownLatch例子
例子:3y现在去做实习生了,其他的员工还没下班,3y不好意思先走,等其他的员工都走光了,3y再走。
import java.util.concurrent.CountDownLatch;
public class Test {
public static void main(String[] args) {
final CountDownLatch countDownLatch = new CountDownLatch(5);
System.out.println("现在6点下班了.....");
new Thread(new
Java多线程面试题,我丝毫不慌
最后发布:2020-07-28 09:18:51 首次发布:2020-07-28 09:18:51
版权
一、什么是多线程
一、初识多线程
1.1介绍进程
讲到线程,又不得不提进程了~
进程我们估计是很了解的了,在windows下打开任务管理器,可以发现我们在操作系统上运行的程序都是进程:
进程的定义:
进程是程序的一次执行,进程是一个程序及其数据在处理机上顺序执行时所发生的活动,进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位
- 进程是系统进行资源分配和调度的独立单位。每一个进程都有它自己的内存空间和系统资源
1.2回到线程
那系统有了进程这么一个概念了,进程已经是可以进行资源分配和调度了,为什么还要线程呢?
为使程序能并发执行,系统必须进行以下的一系列操作:
- (1)创建进程,系统在创建一个进程时,必须为它分配其所必需的、除处理机以外的所有资源,如内存空间、I/O设备,以及建立相应的PCB;
- (2)撤消进程,系统在撤消进程时,又必须先对其所占有的资源执行回收操作,然后再撤消PCB;
- (3)进程切换,对进程进行上下文切换时,需要保留当前进程的CPU环境,设置新选中进程的CPU环境,因而须花费不少的处理机时间。
可以看到进程实现多处理机环境下的进程调度,分派,切换时,都需要花费较大的时间和空间开销
引入线程主要是**为了提高系统的执行效率,减少处理机的空转时间和调度切换的时间,以及便于系统管理。**使OS具有更好的并发性
- 简单来说:进程实现多处理非常耗费CPU的资源,而我们引入线程是作为调度和分派的基本单位(取代进程的部分基本功能**【调度】**)。
那么线程在哪呢??举个例子:
也就是说:在同一个进程内又可以执行多个任务,而这每一个任务我就可以看出是一个线程。
1.3进程与线程
于是我们可以总结出:
- 进程作为资源分配的基本单位
- 线程作为资源调度的基本单位,是程序的执行单元,执行路径(单线程:一条执行路径,多线程:多条执行路径)。是程序使用CPU的最基本单位。
线程有3个基本状态:
线程有5种基本操作:
线程的属性:
- 1)轻型实体;
- 2)独立调度和分派的基本单位;
- 3)可并发执行;
- 4)共享进程资源。
线程有两个基本类型:
-
- 用户级线程:管理过程全部由用户程序完成,操作系统内核心只对进程进行管理。
-
- 系统级线程(核心级线程):由操作系统内核进行管理。操作系统内核给应用程序提供相应的系统调用和应用程序接口API,以使用户程序可以创建、执行以及撤消线程。
值得注意的是:多线程的存在,不是提高程序的执行速度。其实是为了提高应用程序的使用率,程序的执行其实都是在抢CPU的资源,CPU的执行权。多个进程是在抢这个资源,而其中的某一个进程如果执行路径比较多,就会有更高的几率抢到CPU的执行权
1.4并行与并发
并行:
- 并行性是指同一时刻内发生两个或多个事件。
- 并行是在不同实体上的多个事件
并发:
- 并发性是指同一时间间隔内发生两个或多个事件。
- 并发是在同一实体上的多个事件
由此可见:并行是针对进程的,并发是针对线程的。
1.5Java实现多线程
上面说了一大堆基础,理解完的话。我们回到Java中,看看Java是如何实现多线程的~
Java实现多线程是使用Thread这个类的,我们来看看Thread类的顶部注释: