目录
3.6.经典面试题(StringBuilder与StringBuffer)
1.线程的创建方式
1.1方式1
Java为开发者提供了一个类叫做Thread,此类的对象用来表示线程。创建线程并执行线程的步骤如下:
1.定义一个子类继承Thread类,并重写run方法
2.创建Thread的子类对象
3.调用start方法启动线程(启动线程后,会自动执行run方法中的代码)
代码如下:
public class MyThread extends Thread{
// 2、必须重写Thread类的run方法
@Override
public void run() {
// 描述线程的执行任务。
for (int i = 1; i <= 5; i++) {
System.out.println("子线程MyThread输出:" + i);
}
}
}
再定义一个测试类,在测试类中创建MyThread线程对象,并启动线程
public class ThreadTest1 {
// main方法是由一条默认的主线程负责执行。
public static void main(String[] args) {
// 3、创建MyThread线程类的对象代表一个线程
Thread t = new MyThread();
// 4、启动线程(自动执行run方法的)
t.start();
for (int i = 1; i <= 5; i++) {
System.out.println("主线程main输出:" + i);
}
}
}
打印结果如下图所示,我们会发现MyThread和main线程在相互抢夺CPU的执行权(注意:哪一个线程先执行,哪一个线程后执行,目前我们是无法控制的,每次输出结果都会不一样)
最后我们还需要注意一点:不能直接去调用run方法,如果直接调用run方法就不认为是一条线程启动了,而是把Thread当做一个普通对象,此时run方法中的执行的代码会成为主线程的一部分。此时执行结果是这样的。
1.2.方式2
接下来我们学习线程的第二种创建方式。Java为开发者提供了一个Runnable接口,该接口中只有一个run方法,意思就是通过Runnable接口的实现类对象专门来表示线程要执行的任务。具体步骤如下
1.先写一个Runnable接口的实现类,重写run方法(这里面就是线程要执行的代码)
2.再创建一个Runnable实现类的对象
3.创建一个Thread对象,把Runnable实现类的对象传递给Thread
4.调用Thread对象的start()方法启动线程(启动后会自动执行Runnable里面的run方法)
代码如下:先准备一个Runnable接口的实现类
/**
* 1、定义一个任务类,实现Runnable接口
*/
public class MyRunnable implements Runnable{
// 2、重写runnable的run方法
@Override
public void run() {
// 线程要执行的任务。
for (int i = 1; i <= 5; i++) {
System.out.println("子线程输出 ===》" + i);
}
}
}
再写一个测试类,在测试类中创建线程对象,并执行线程
public class ThreadTest2 {
public static void main(String[] args) {
// 3、创建任务对象。
Runnable target = new MyRunnable();
// 4、把任务对象交给一个线程对象处理。
// public Thread(Runnable target)
new Thread(target).start();
for (int i = 1; i <= 5; i++) {
System.out.println("主线程main输出 ===》" + i);
}
}
}
运行上面代码,结果如下图所示(注意:没有出现下面交替执行的效果,也是正常的)
1.3.线程创建方式2—匿名内部类
将前面第二种方式用匿名内部类改写一下。第二种线程的创建方式,需要写一个Runnable接口的实现类,然后再把Runnable实现类的对象传递给Thread对象。现在我不想写Runnable实现类,于是可以直接创建Runnable接口的匿名内部类对象,传递给Thread对象。
public class ThreadTest2_2 {
public static void main(String[] args) {
// 1、直接创建Runnable接口的匿名内部类形式(任务对象)
Runnable target = new Runnable() {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println("子线程1输出:" + i);
}
}
};
new Thread(target).start();
// 简化形式1:
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println("子线程2输出:" + i);
}
}
}).start();
// 简化形式2:
new Thread(() -> {
for (int i = 1; i <= 5; i++) {
System.out.println("子线程3输出:" + i);
}
}).start();
for (int i = 1; i <= 5; i++) {
System.out.println("主线程main输出:" + i);
}
}
}
1.4.线程的创建方式3
我们先分析一下前面两种都存在的一个问题。然后再引出第三种可以解决这个问题。 假设线程执行完毕之后有一些数据需要返回,前面两种方式重写的run方法均没有返回结果。
public void run(){
...线程执行的代码...
}
JDK5提供了Callable接口和FutureTask类来创建线程,它最大的优点就是有返回值。在Callable接口中有一个call方法,重写call方法就是线程要执行的代码,它是有返回值的
public T call(){
...线程执行的代码...
return 结果;
}
第三种创建线程的方式,步骤如下 :
1.先定义一个Callable接口的实现类,重写call方法
2.创建Callable实现类的对象
3.创建FutureTask类的对象,将Callable对象传递给FutureTask
4.创建Thread对象,将Future对象传递给Thread
5.调用Thread的start()方法启动线程(启动后会自动执行call方法)
等call()方法执行完之后,会自动将返回值结果封装到FutrueTask对象中
6.调用FutrueTask对的get()方法获取返回结果
代码如下:先准备一个Callable接口的实现类
2.多线程常用的方法
下面我们演示一下getName()
、setName(String name)
、currentThread()
、sleep(long time)
这些方法的使用效果。
public class MyThread extends Thread{
public MyThread(String name){
super(name); //1.执行父类Thread(String name)构造器,为当前线程设置名字了
}
@Override
public void run() {
//2.currentThread() 哪个线程执行它,它就会得到哪个线程对象。
Thread t = Thread.currentThread();
for (int i = 1; i <= 3; i++) {
//3.getName() 获取线程名称
System.out.println(t.getName() + "输出:" + i);
}
}
}
再测试类中,创建线程对象,并启动线程
public class ThreadTest1 {
public static void main(String[] args) {
Thread t1 = new MyThread();
t1.setName(String name) //设置线程名称;
t1.start();
System.out.println(t1.getName()); //Thread-0
Thread t2 = new MyThread("2号线程");
// t2.setName("2号线程");
t2.start();
System.out.println(t2.getName()); // Thread-1
// 主线程对象的名字
// 哪个线程执行它,它就会得到哪个线程对象。
Thread m = Thread.currentThread();
m.setName("最牛的线程");
System.out.println(m.getName()); // main
for (int i = 1; i <= 5; i++) {
System.out.println(m.getName() + "线程输出:" + i);
}
}
}
执行上面代码,效果如下图所示,我们发现每一条线程都有自己了名字了。
最后再演示一下join这个方法是什么效果。
public class ThreadTest2 {
public static void main(String[] args) throws Exception {
// join方法作用:让当前调用这个方法的线程先执行完。
Thread t1 = new MyThread("1号线程");
t1.start();
t1.join();
Thread t2 = new MyThread("2号线程");
t2.start();
t2.join();
Thread t3 = new MyThread("3号线程");
t3.start();
t3.join();
}
}
执行效果是1号线程先执行完,再执行2号线程;2号线程执行完,再执行3号线程;3号线程执行完就结束了。
我们再尝试,把join()方法去掉,再看执行效果。此时你会发现2号线程没有执行完1号线程就执行了(效果是多次运行才出现的,根据个人电脑而异,可能有同学半天也出现不了也是正常的)
3.线程安全问题
3.1.线程安全问题概述
线程安全问题指的是,多个线程同时操作同一个共享资源的时候,可能会出现业务安全问题。
下面通过一个取钱的案例给同学们演示一下。案例需求如下:场景:小明和小红是一对夫妻,他们有一个共享账户,余额是10万元,小红和小明同时来取钱,并且2人各自都在取钱10万元,可能出现什么问题呢?
如下图所示,小明和小红假设都是一个线程,本类每个线程都应该执行完三步操作,才算是完成的取钱的操作。但是真实执行过程可能是下面这样子的。
① 小红线程只执行了判断余额是否足够(条件为true),然后CPU的执行权就被小红线程抢走了。
② 小红线程也执行了判断了余额是否足够(条件也是true), 然后CPU执行权又被小明线程抢走了。
③ 小明线程由于刚才已经判断余额是否足够了,直接执行第2步,吐出了10万元钱,此时共享账户月为0。然后CPU执行权又被小红线程抢走。
④ 小红线程由于刚刚也已经判断余额是否足够了,直接执行第2步,吐出了10万元钱,此时共享账户月为-10万。
你会发现,在这个取钱案例中,两个人把共享账户的钱都取了10万,但问题是只有10万块钱啊!!!以上取钱案例中的问题,就是线程安全问题的一种体现。
3.2 线程安全问题的代码演示
先定义一个共享的账户类:
public class Account {
private String cardId; // 卡号
private double money; // 余额。
public Account() {
}
public Account(String cardId, double money) {
this.cardId = cardId;
this.money = money;
}
// 小明 小红同时过来的
public void drawMoney(double money) {
// 先搞清楚是谁来取钱?
String name = Thread.currentThread().getName();
// 1、判断余额是否足够
if(this.money >= money){
System.out.println(name + "来取钱" + money + "成功!");
this.money -= money;
System.out.println(name + "来取钱后,余额剩余:" + this.money);
}else {
System.out.println(name + "来取钱:余额不足~");
}
}
public String getCardId() {
return cardId;
}
public void setCardId(String cardId) {
this.cardId = cardId;
}
public double getMoney() {
return money;
}
public void setMoney(double money) {
this.money = money;
}
}
在定义一个是取钱的线程类:
public class DrawThread extends Thread{
private Account acc;
public DrawThread(Account acc, String name){
super(name);
this.acc = acc;
}
@Override
public void run() {
// 取钱(小明,小红)
acc.drawMoney(100000);
}
}
最后,再写一个测试类,在测试类中创建两个线程对象
public class ThreadTest {
public static void main(String[] args) {
// 1、创建一个账户对象,代表两个人的共享账户。
Account acc = new Account("ICBC-110", 100000);
// 2、创建两个线程,分别代表小明 小红,再去同一个账户对象中取钱10万。
new DrawThread(acc, "小明").start(); // 小明
new DrawThread(acc, "小红").start(); // 小红
}
}
运行程序,执行效果如下。你会发现两个人都取了10万块钱,余额为-10完了。
3.3.线程同步方案
为了解决前面的线程安全问题,我们可以使用线程同步思想。同步最常见的方案就是加锁,意思是每次只允许一个线程加锁,加锁后才能进入访问,访问完毕后自动释放锁,然后其他线程才能再加锁进来。
等小红线程执行完了,把余额改为0,出去了就会释放锁。这时小明线程就可以加锁进来执行,如下图所示。
采用加锁的方案,就可以解决前面两个线程都取10万块钱的问题。怎么加锁呢?Java提供了三种方案
1.同步代码块
2.同步方法
3.Lock锁
3.4.同步代码块
我们先来学习同步代码块。它的作用就是把访问共享数据的代码锁起来,以此保证线程安全。
//锁对象:必须是一个唯一的对象(同一个地址)
synchronized(锁对象){
//...访问共享数据的代码...
}
使用同步代码块,来解决前面代码里面的线程安全问题。我们只需要修改DrawThread类中的代码即可。
// 小明 小红线程同时过来的
public void drawMoney(double money) {
// 先搞清楚是谁来取钱?
String name = Thread.currentThread().getName();
// 1、判断余额是否足够
// this正好代表共享资源!
synchronized (this) {
if(this.money >= money){
System.out.println(name + "来取钱" + money + "成功!");
this.money -= money;
System.out.println(name + "来取钱后,余额剩余:" + this.money);
}else {
System.out.println(name + "来取钱:余额不足~");
}
}
}
最后,再给同学们说一下锁对象如何选择的问题(锁对象必须唯一)
1.建议把共享资源作为锁对象, 不要将随便无关的对象当做锁对象
2.对于实例方法,建议使用this作为锁对象
3.对于静态方法,建议把类的字节码(类名.class)当做锁对象
3.5 同步方法
接下来,学习同步方法解决线程安全问题。其实同步方法,就是把整个方法给锁住,一个线程调用这个方法,另一个线程调用的时候就执行不了,只有等上一个线程调用结束,下一个线程调用才能继续执行。
// 同步方法
public synchronized void drawMoney(double money) {
// 先搞清楚是谁来取钱?
String name = Thread.currentThread().getName();
// 1、判断余额是否足够
if(this.money >= money){
System.out.println(name + "来取钱" + money + "成功!");
this.money -= money;
System.out.println(name + "来取钱后,余额剩余:" + this.money);
}else {
System.out.println(name + "来取钱:余额不足~");
}
}
接着,再问同学们一个问题,同步方法有没有锁对象?锁对象是谁?
同步方法也是有锁对象,只不过这个锁对象没有显示的写出来而已。
1.对于实例方法,锁对象其实是this(也就是方法的调用者)
2.对于静态方法,锁对象时类的字节码对象(类名.class)
最终,总结一下同步代码块和同步方法有什么区别?
1.不存在哪个好与不好,只是一个锁住的范围大,一个范围小
2.同步方法是将方法中所有的代码锁住
3.同步代码块是将方法中的部分代码锁住
3.6.经典面试题(StringBuilder与StringBuffer)
在面试中,经常会问String、StringBuilder、StringBuffer的区别,
-
String
是不可变的(immutable)。一旦创建了一个String
对象,你就不能改变这个对象中的字符串内容。如果你需要一个新的字符串,字符串表达式会自动创建一个新的String
对象。 -
StringBuilder
和StringBuffer
都是可变的(mutable)。你可以修改StringBuilder
和StringBuffer
对象中的字符串内容,而不需要创建新的字符串对象。
StringBuilder与StringBuffer的区别,StringBuilder是线程不安全的!!StringBuffer是线程安全的。
来看源码:
可以看到StringBuffer 的成员方法都是同步方法,都加了synchronized 关键字。
3.7.synchronized有什么缺点?
性能开销大
- 重量级锁:在 Java 早期版本中,
synchronized
是一个重量级锁,当一个线程获取锁时,会涉及到用户态和内核态的切换,这种切换是非常消耗性能的。即使在 Java 6 之后对synchronized
进行了优化,引入了偏向锁、轻量级锁等机制,但在高并发场景下,锁竞争激烈时,依然会退化为重量级锁,导致性能下降。 - 锁粒度问题:
synchronized
的锁粒度相对较大,它要么锁住整个方法,要么锁住代码块。如果方法或代码块中的操作较多,其中部分操作并不需要同步,那么使用synchronized
会导致其他线程长时间等待,降低了并发性能。
缺乏灵活性
- 锁的获取和释放方式单一:
synchronized
锁的获取和释放是隐式的,由 Java 虚拟机自动完成。开发者无法手动控制锁的获取和释放时机,这在一些复杂的场景下可能无法满足需求。例如,在某些情况下,开发者可能希望在获取锁失败时进行一些其他操作,而不是一直等待,但synchronized
无法实现这样的灵活控制。
可扩展性差
- 读写场景性能不佳:
synchronized
不区分读写操作,无论是读操作还是写操作都需要加锁,这在读写频繁的场景下会影响性能。因为多个线程同时进行读操作并不会产生数据不一致的问题,而使用synchronized
会让读操作也串行执行,降低了并发性能。
解决方案:
针对可扩展性差:使用读写锁,在读写频繁的场景下,使用 ReentrantReadWriteLock
可以提高并发性能。多个线程可以同时获取读锁进行读操作,而写操作需要独占锁。
针对灵活性问题:使用 Lock
接口及其实现类java.util.concurrent.locks
包中的 Lock
接口及其实现类(如 ReentrantLock
)提供了更灵活的锁控制。可以手动获取和释放锁,支持锁的可中断性和超时机制。
3.8.Lock锁
Lock锁是JDK5版本专门提供的一种锁对象,通过这个锁对象的方法来达到加锁,和释放锁的目的,使用起来更加灵活。格式如下
1.首先在成员变量位置,需要创建一个Lock接口的实现类对象(这个对象就是锁对象)
private final Lock lk = new ReentrantLock();
2.在需要上锁的地方加入下面的代码
lk.lock(); // 加锁
//...中间是被锁住的代码...
lk.unlock(); // 解锁
使用Lock锁改写前面DrawThread中取钱的方法,代码如下:
// 创建了一个锁对象
private final Lock lk = new ReentrantLock();
public void drawMoney(double money) {
// 先搞清楚是谁来取钱?
String name = Thread.currentThread().getName();
try {
lk.lock(); // 加锁
// 1、判断余额是否足够
if(this.money >= money){
System.out.println(name + "来取钱" + money + "成功!");
this.money -= money;
System.out.println(name + "来取钱后,余额剩余:" + this.money);
}else {
System.out.println(name + "来取钱:余额不足~");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lk.unlock(); // 解锁
}
}
}
4.线程池
4.1线程池概述
什么是线程池技术? 其实,线程池就是一个可以复用线程的技术。 要理解什么是线程复用技术,我们先得看一下不使用线程池会有什么问题,理解了这些问题之后,我们在解释线程复用同学们就好理解了。
假设:用户每次发起一个请求给后台,后台就创建一个新的线程来处理,下次新的任务过来肯定也会创建新的线程,如果用户量非常大,创建的线程也讲越来越多。然而,创建线程是开销很大的,并且请求过多时,会严重影响系统性能。
而使用线程池,就可以解决上面的问题。如下图所示,线程池内部会有一个容器,存储几个核心线程,假设有3个核心线程,这3个核心线程可以处理3个任务。
但是任务总有被执行完的时候,假设第1个线程的任务执行完了,那么第1个线程就空闲下来了,有新的任务时,空闲下来的第1个线程可以去执行其他任务。依此内推,这3个线程可以不断的复用,也可以执行很多个任务。
所以,线程池就是一个线程复用技术,它可以提高线程的利用率。
4.2.创建线程池
在JDK5版本中提供了代表线程池的接口ExecutorService,而这个接口下有一个实现类叫ThreadPoolExecutor类,使用ThreadPoolExecutor类就可以用来创建线程池对象。下面是它的构造器,参数比较多,不要怕,干就完了!!!
接下来,用这7个参数的构造器来创建线程池的对象。代码如下
ExecutorService pool = new ThreadPoolExecutor(
3, //核心线程数有3个
5, //最大线程数有5个。 临时线程数=最大线程数-核心线程数=5-3=2
8, //临时线程存活的时间8秒。 意思是临时线程8秒没有任务执行,就会被销毁掉。
TimeUnit.SECONDS,//时间单位(秒)
new ArrayBlockingQueue<>(4), //任务阻塞队列,没有来得及执行的任务在,任务队列中等待
Executors.defaultThreadFactory(), //用于创建线程的工厂对象
new ThreadPoolExecutor.CallerRunsPolicy() //拒绝策略
);
关于线程池,我们需要注意下面的两个问题:
- 临时线程什么时候创建?
新任务提交时,发现核心线程都在忙、任务队列满了、并且还可以创建临时线程,此时会创建临时线程。
- 什么时候开始拒绝新的任务?
核心线程和临时线程都在忙、任务队列也满了、新任务过来时才会开始拒绝任务。
4.3.线程池执行Runnable任务
创建好线程池之后,接下来我们就可以使用线程池执行任务了。线程池执行的任务可以有两种,一种是Runnable任务;一种是callable任务。下面的execute方法可以用来执行Runnable任务。
先准备一个线程任务类:
public class MyRunnable implements Runnable{
@Override
public void run() {
// 任务是干啥的?
System.out.println(Thread.currentThread().getName() + " ==> 输出666~~");
//为了模拟线程一直在执行,这里睡久一点
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
下面是执行Runnable任务的代码,注意阅读注释,对照着前面的7个参数理解。
ExecutorService pool = new ThreadPoolExecutor(
3, //核心线程数有3个
5, //最大线程数有5个。 临时线程数=最大线程数-核心线程数=5-3=2
8, //临时线程存活的时间8秒。 意思是临时线程8秒没有任务执行,就会被销毁掉。
TimeUnit.SECONDS,//时间单位(秒)
new ArrayBlockingQueue<>(4), //任务阻塞队列,没有来得及执行的任务在,任务队列中等待
Executors.defaultThreadFactory(), //用于创建线程的工厂对象
new ThreadPoolExecutor.CallerRunsPolicy() //拒绝策略
);
Runnable target = new MyRunnable();
pool.execute(target); // 线程池会自动创建一个新线程,自动处理这个任务,自动执行的!
pool.execute(target); // 线程池会自动创建一个新线程,自动处理这个任务,自动执行的!
pool.execute(target); // 线程池会自动创建一个新线程,自动处理这个任务,自动执行的!
//下面4个任务在任务队列里排队
pool.execute(target);
pool.execute(target);
pool.execute(target);
pool.execute(target);
//下面2个任务,会被临时线程的创建时机了
pool.execute(target);
pool.execute(target);
// 到了新任务的拒绝时机了!
pool.execute(target);
执行上面的代码,结果输出如下:
4.4.线程池执行Callable任务
接下来,我们学习使用线程池执行Callable任务。callable任务相对于Runnable任务来说,就是多了一个返回值。执行Callable任务需要用到下面的submit方法。
先准备一个Callable线程任务
public class MyCallable implements Callable<String> {
private int n;
public MyCallable(int n) {
this.n = n;
}
// 2、重写call方法
@Override
public String call() throws Exception {
// 描述线程的任务,返回线程执行返回后的结果。
// 需求:求1-n的和返回。
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i;
}
return Thread.currentThread().getName() + "求出了1-" + n + "的和是:" + sum;
}
}
再准备一个测试类,在测试类中创建线程池,并执行callable任务。
public class ThreadPoolTest2 {
public static void main(String[] args) throws Exception {
// 1、通过ThreadPoolExecutor创建一个线程池对象。
ExecutorService pool = new ThreadPoolExecutor(
3,
5,
8,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(4),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy());
// 2、使用线程处理Callable任务。
Future<String> f1 = pool.submit(new MyCallable(100));
Future<String> f2 = pool.submit(new MyCallable(200));
Future<String> f3 = pool.submit(new MyCallable(300));
Future<String> f4 = pool.submit(new MyCallable(400));
// 3、执行完Callable任务后,需要获取返回结果。
System.out.println(f1.get());
System.out.println(f2.get());
System.out.println(f3.get());
System.out.println(f4.get());
}
}
执行后,结果如下图所示:
4.5 线程池工具类(Executors)
有同学可能会觉得前面创建线程池的代码参数太多、记不住,有没有快捷的创建线程池的方法呢?有的。Java为开发者提供了一个创建线程池的工具类,叫做Executors,它提供了方法可以创建各种不能特点的线程池。如下图所示:
接下来,演示一下创建固定线程数量的线程池。这几个方法用得不多,所以这里不做过多演示,了解一下就行了。
public class ThreadPoolTest3 {
public static void main(String[] args) throws Exception {
// 1、通过Executors创建一个线程池对象。
ExecutorService pool = Executors.newFixedThreadPool(17);
// 老师:核心线程数量到底配置多少呢???
// 计算密集型的任务:核心线程数量 = CPU的核数 + 1
// IO密集型的任务:核心线程数量 = CPU核数 * 2
// 2、使用线程处理Callable任务。
Future<String> f1 = pool.submit(new MyCallable(100));
Future<String> f2 = pool.submit(new MyCallable(200));
Future<String> f3 = pool.submit(new MyCallable(300));
Future<String> f4 = pool.submit(new MyCallable(400));
System.out.println(f1.get());
System.out.println(f2.get());
System.out.println(f3.get());
System.out.println(f4.get());
}
}
Executors创建线程池这么好用,为什么不推荐同学们使用呢?原因在这里:看下图,这是《阿里巴巴Java开发手册》提供的强制规范要求。
4.6 线程的生命周期
所谓生命周期就是线程从生到死的过程中间有哪些状态,以及这些状态之间是怎么切换的。
NEW: 新建状态,线程还没有启动
RUNNABLE: 可以运行状态,线程调用了start()方法后处于这个状态
BLOCKED: 锁阻塞状态,没有获取到锁处于这个状态
WAITING: 无限等待状态,线程执行时被调用了wait方法处于这个状态
TIMED_WAITING: 计时等待状态,线程执行时被调用了sleep(毫秒)或者wait(毫秒)方法处于这个状态
TERMINATED: 终止状态, 线程执行完毕或者遇到异常时,处于这个状态。
4.7.线程池工作队列满了有哪些拒接策略?
当线程池的任务队列满了之后,线程池会执行指定的拒绝策略来应对,常用的四种拒绝策略包括:CallerRunsPolicy、AbortPolicy、DiscardPolicy、DiscardOldestPolicy,此外,还可以通过实现RejectedExecutionHandler接口来自定义拒绝策略。
四种预置的拒绝策略:
- CallerRunsPolicy,使用线程池的调用者所在的线程去执行被拒绝的任务,除非线程池被停止或者线程池的任务队列已有空缺。
- AbortPolicy,直接抛出一个任务被线程池拒绝的异常。
- DiscardPolicy,不做任何处理,静默拒绝提交的任务。
- DiscardOldestPolicy,抛弃最老的任务,然后执行该任务。
- 自定义拒绝策略,通过实现接口可以自定义任务拒绝策略。
5.多线程
5.1.Java里面的多线程和操作系统里面的线程一样吗?
Java 底层会调用 pthread_create 来创建线程,所以本质上 java 程序创建的线程,就是和操作系统线程是一样的,是 1 对 1 的线程模型。
5.2.使用多线程要注意哪些问题?
要保证多线程的程序是安全,不要出现数据竞争造成的数据混乱的问题。Java的线程安全在三个方面体现:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性;
- 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。
5.3.如何停止一个线程的运行
主要有这些方法:
- 异常法停止:线程调用interrupt()方法后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。
- 在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果。
- stop()暴力停止:线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。
- 使用return停止线程:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。
5.4.sleep 和 wait的区别是什么?
对比例表:
- 所属分类的不同:sleep 是
Thread
类的静态方法,可以在任何地方直接过Thread.sleep()
调用,无需依赖对象实例。wait 是Object
类的实例方法,这意味着必须通过对象实例来调用。 - 锁释放的情况:
Thread.sleep()
在调用时,线程会暂停执行指定的时间,但不会释放持有的对象锁。也就是说,在sleep
期间,其他线程无法获得该线程持有的锁。Object.wait()
:调用该方法时,线程会释放持有的对象锁,进入等待状态,直到其他线程调用相同对象的notify()
或notifyAll()
方法唤醒它 - 使用条件:sleep 可在任意位置调用,无需事先获取锁。 wait 必须在同步块或同步方法内调用(即线程需持有该对象的锁),否则抛出
IllegalMonitorStateException
。 - 唤醒机制:sleep 休眠时间结束后,线程 自动恢复 到就绪状态,等待CPU调度。wait 需要其他线程调用相同对象的
notify()
或notifyAll()
方法才能被唤醒。notify()
会随机唤醒一个在该对象上等待的线程,而notifyAll()
会唤醒所有在该对象上等待的线程。
5.5.sleep会释放cpu吗?
是的,调用 Thread.sleep()
时,线程会释放 CPU,但不会释放持有的锁。当线程用 sleep()
后,会主动让出 CPU 时间片,进入 TIMED_WAITING
状态。此时操作系统会触发调度,将 CPU 分配给其他处于就绪状态的线程。这样其他线程(无论是需要同一锁的线程还是不相关线程)便有机会执行。
sleep()
不会释放线程已持有的任何锁(如 synchronized
同步代码块或方法中获取的锁)。因此,如果有其他线程试图获取同一把锁,它们仍会被阻塞,直到原线程退出同步代码块。
6.锁
6.1.Monitor
Monitor 被翻译为监视器或管程,每个 Java 对象都可以关联一个 Monitor 对象,Monitor 也是 class,其实例存储在堆中,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针,这就是重量级锁。
Mark Word 结构:最后两位是锁标志位
工作流程:
- 开始时 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中(轻量级锁部分详解)
- 在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList BLOCKED(双向链表)
- Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
- 唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
- WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)
注意:
- synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
6.2.锁升级
升级过程:synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争的增加,只能锁升级,不能降级
6.3.偏向锁
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要同步操作:偏向锁是对轻量级锁的优化
-
当锁对象第一次被线程获得的时候进入偏向状态,标记为 101,同时使用 CAS 操作将线程 ID 记录到 Mark Word。如果 CAS 操作成功,这个线程以后进入这个锁相关的同步块,查看这个线程 ID ,是自己的就表示没有竞争,就不需要再进行任何同步操作。
-
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定或轻量级锁状态
一个对象创建时:
-
如果开启了偏向锁(默认开启),那么对象创建后,MarkWord 值为 0x05 即最后 3 位为 101,thread、epoch、age 都为 0
-
偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
-XX:BiasedLockingStartupDelay=0
来禁用延迟。JDK 8 延迟 4s 开启偏向锁原因:在刚开始执行代码时,会有好多线程来抢锁,如果开偏向锁效率反而降低 -
当一个对象已经计算过 hashCode,就再也无法进入偏向状态了
-
添加 VM 参数
-XX:-UseBiasedLocking
禁用偏向锁
撤销偏向锁的状态:
- 调用对象的 hashCode:偏向锁的对象 MarkWord 中存储的是线程 id,调用 hashCode 导致偏向锁被撤销
- 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
- 调用 wait/notify,需要申请 Monitor,进入 WaitSet
6.4.轻量级锁
一个对象有多个线程要加锁,但加锁的时间是错开的(没有竞争),可以使用轻量级锁来优化,轻量级锁对使用者是透明的(不可见)
可重入锁:线程可以进入任何一个它已经拥有的锁所同步着的代码块,可重入锁最大的作用是避免死锁
轻量级锁在没有竞争时(锁重入时),每次重入仍然需要执行 CAS 操作,Java 6 才引入的偏向锁来优化,锁重入示例:
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的 Mark Word
让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录。如果 CAS 替换成功,对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁。
如果 CAS 失败,有两种情况:
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是线程自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数
当退出 synchronized 代码块(解锁时):
- 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
- 如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
6.5.锁膨胀
在尝试加轻量级锁的过程中,CAS 操作无法成功,可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
6.6.锁优化
自旋锁
重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁。
注意:
- 自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势
- 自旋失败的线程会进入阻塞状态
优点:不会进入阻塞状态,减少线程上下文切换的消耗
缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源
自旋锁情况:
- 自旋成功的情况:
- 自旋失败的情况:
自旋锁说明:
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
- Java 7 之后不能控制是否开启自旋功能,由 JVM 控制
7.ReentrantLock
可重入锁:指同一个线程如果首次获得了这把锁,那么因为他是这把锁的拥有者,因此有权利再次获取这把锁,如果是不可重入锁,那么第二次获得锁时,自己也会被挡住。
ReentrantLock 相对于 synchronized 具备如下特点:
- 锁的实现:synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的
- 性能:新版本 Java 对 synchronized 进行了很多优化,synchronized 与 ReentrantLock 大致相同
- 使用:ReentrantLock 需要手动解锁,synchronized 执行完代码块自动解锁
- 可中断:ReentrantLock 可中断,而 synchronized 不行
- 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
- ReentrantLock 可以设置公平锁,synchronized 中的锁是非公平的
- 不公平锁的含义是阻塞队列内公平,队列外非公平
- 锁超时:尝试获取锁,超时获取不到直接放弃,不进入阻塞队列
- ReentrantLock 可以设置超时时间,synchronized 会一直等待
- 锁绑定多个条件:一个 ReentrantLock 可以同时绑定多个 Condition 对象,更细粒度的唤醒线程
- 两者都是可重入锁
7.1.可打断
可打断锁的意义在于它加入了一种打断机制,可以防止你无限制的等待下去,这也是一种可以避免死锁的做法。
7.2.锁超时
上述可打断方法是一种被动的避免死锁的方法,需要别人告诉你不要再等下去了。锁超时是一种主动的方式,来避免死等的一个方式。
7.3.公平锁
基本使用
构造方法:ReentrantLock lock = new ReentrantLock(true)
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
ReentrantLock 默认是不公平的:
public ReentrantLock() {
sync = new NonfairSync();
}
说明:公平锁一般没有必要,会降低并发度。
8.CAS
原理:无锁编程
CAS 的全称是 Compare-And-Swap,是 CPU 并发原语
- CAS 并发原语体现在 Java 语言中就是 sun.misc.Unsafe 类的各个方法,调用 UnSafe 类中的 CAS 方法,JVM 会实现出 CAS 汇编指令,这是一种完全依赖于硬件的功能,实现了原子操作
- CAS 是一种系统原语,原语属于操作系统范畴,是由若干条指令组成 ,用于完成某个功能的一个过程,并且原语的执行必须是连续的,执行过程中不允许被中断,所以 CAS 是一条 CPU 的原子指令,不会造成数据不一致的问题,是线程安全的。
底层原理:CAS 的底层是 lock cmpxchg
指令(X86 架构),在单核和多核 CPU 下都能够保证比较交换的原子性
作用:比较当前工作内存中的值和主物理内存中的值,如果相同则执行规定操作,否则继续比较直到主内存和工作内存的值一致为止。
CAS 特点:
- CAS 体现的是无锁并发、无阻塞并发,线程不会陷入阻塞,线程不需要频繁切换状态(上下文切换,系统调用)
- CAS 是基于乐观锁的思想
CAS 缺点:
- 执行的是循环操作,如果比较不成功一直在循环,最差的情况某个线程一直取到的值和预期值都不一样,就会无限循环导致饥饿,使用 CAS 线程数不要超过 CPU 的核心数,采用分段 CAS 和自动迁移机制
- 只能保证一个共享变量的原子操作
- 对于一个共享变量执行操作时,可以通过循环 CAS 的方式来保证原子操作
- 对于多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候只能用锁来保证原子性
- 引出来 ABA 问题
ABA问题 是CAS(Compare and Swap,比较并交换)操作中的一个常见问题,尤其在并发编程中。它指的是当线程执行CAS操作时,检查某个变量的值是否为预期值,如果是,则执行更新操作。然而,在这个过程中,如果变量的值在其他线程的操作下发生了变化,但最终又回到原来的值,那么CAS操作可能无法检测到这种变化,仍然认为变量没有变化,从而导致数据不一致。
具体的ABA问题解释:
- 假设线程A执行CAS操作,它期望变量
x
的值为A
,并且如果是A
,它将把它更新为B
。 - 但是在这个过程中,线程B可能也访问了
x
并对其进行了更改,假设x
从A
变为C
,然后又变回A
。 - 当线程A再次检查
x
时,它发现x
仍然是A
,并执行更新,但实际上x
已经在其他线程中发生了变化,从而导致数据不一致。
ABA问题的后果:
- 在并发环境下,ABA问题可能导致程序逻辑错误或数据不一致,尤其是当多个线程修改同一个共享变量时。
- 虽然CAS操作检查变量的值是否符合预期(如
A
),但是并没有捕捉到变量值被其他线程修改和恢复的情况。
解决方案:改用其他同步机制:例如,使用锁(如ReentrantLock
)或原子操作类(如AtomicReference
)来替代CAS操作,避免ABA问题的发生。
8.1.乐观锁
CAS 与 synchronized 总结:
- synchronized 是从悲观的角度出发:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程),因此 synchronized 也称之为悲观锁,ReentrantLock 也是一种悲观锁,性能较差
- CAS 是从乐观的角度出发:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。如果别人修改过,则获取现在最新的值,如果别人没修改过,直接修改共享数据的值,CAS 这种机制也称之为乐观锁,综合性能较好。
9.ThreadLocal底层原理
9.1.基本介绍
ThreadLocal 类用来提供线程内部的局部变量,这种变量在多线程环境下访问(通过 get 和 set 方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。ThreadLocal 实例通常来说都是 private static
类型的,属于一个线程的本地变量,用于关联线程和线程上下文。每个线程都会在 ThreadLocal 中保存一份该线程独有的数据,所以是线程安全的。
ThreadLocal 作用:
-
线程并发:应用在多线程并发的场景下
-
传递数据:通过 ThreadLocal 实现在同一线程不同函数或组件中传递公共变量,减少传递复杂度
-
线程隔离:每个线程的变量都是独立的,不会互相影响
对比 synchronized:
应用场景:
ThreadLocal 适用于下面两种场景:
- 每个线程需要有自己单独的实例
- 实例需要在多个方法中共享,但不希望被多线程共享
ThreadLocal 方案有两个突出的优势:
- 传递数据:保存每个线程绑定的数据,在需要的地方可以直接获取,避免参数直接传递带来的代码耦合问题
- 线程隔离:各线程之间的数据相互隔离却又具备并发性,避免同步方式带来的性能损失.
9.2.实现原理
底层结构:
JDK8 以前:每个 ThreadLocal 都创建一个 Map,然后用线程作为 Map 的 key,要存储的局部变量作为 Map 的 value,达到各个线程的局部变量隔离的效果。这种结构会造成 Map 结构过大和内存泄露,因为 Thread 停止后无法通过 key 删除对应的数据。
JDK8 以后:每个 Thread 维护一个 ThreadLocalMap,这个 Map 的 key 是 ThreadLocal 实例本身,value 是真正要存储的值。
- 每个 Thread 线程内部都有一个 Map (ThreadLocalMap)
- Map 里面存储 ThreadLocal 对象(key)和线程的私有变量(value)
- Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 map 获取和设置线程的变量值
- 对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成副本的隔离,互不干扰
JDK8 前后对比:
- 每个 Map 存储的 Entry 数量会变少,因为之前的存储数量由 Thread 的数量决定,现在由 ThreadLocal 的数量决定,在实际编程当中,往往 ThreadLocal 的数量要少于 Thread 的数量
- 当 Thread 销毁之后,对应的 ThreadLocalMap 也会随之销毁,能减少内存的使用,防止内存泄露