《JavaEE初阶》多线程基础
文章目录
- 《JavaEE初阶》多线程基础
- 前言:
- 多线程的概念
- 简单创建线程并运行:
- 简述Thread中run方法与start方法的区别
- 创建线程的几种方法:
- 探讨串行执行与并行执行的执行时间
- 多线程的使用场景:
- Thread类简单介绍:
- 构造方法:
- 获取线程的常见属性:
- 线程的常用方法(important)
- start() - > (启动线程)
- interrupt() -> (中断线程)
- join() -> (线程等待)
- sleep() -> (休眠线程)
- 线程的六个状态:
- 线程安全问题
- 线程不安全引入样例
- 造成线程不安全的原因:
- 使用synchronized关键字来保证原子性
- 含义:
- synchronized的执行流程
- synchornized的使用与优缺点
- 使用volatile来解决编译器优化带来的线程不安全问题
- Java标准库中的线程安全类:
- 使用wait与notify更好地控制线程的执行顺序
- 方法:
- 代码理解:
- 能有效避免"线程饿死"
- wait()和sleep()的区别:
- 多线程案例:
- 单例模式:
- 饿汉模式:
- 懒汉模式:
- 生产者消费者模型的介绍:
- 实现阻塞队列
- 定时器
- 线程池
前言:
本章主要解决一下问题:
-
理解多线程含义
-
理解多线程的创建方法以及基本使用.
-
理解锁的基本概念
-
理解并实现多线程的基本案例
多线程的概念
我们在理解了进程与线程的基本知识下((784条消息) 《JavaEE初阶》进程与线程_小连~的博客-CSDN博客)引入多线程的基本概念,
多线程顾名思义,就是在一个进程中有多个任务,我们希望可以通过进程中创建多个线程来共同完成这个进程中的任务,已达到提高效率的目的.
以下列代码为例:
一个java进程中,运行main线程:
public class Main{
public static void main(String[] avgs){
System.out.println("hello main");
}
}
运行了这个java程序,操作系统就会对应创建一个java进程,同时java进程之中就会有一个线程去调用main方法.
在这个java进程中,我们并没有手动创建出多个线程,但是java进程本身在运行的过程中,已经在内部创建出了多个线程,以辅助代码执行与调试.
简单创建线程并运行:
class Mythead extends Thread{
@Override
public void run() {
while(true) {
System.out.println("hello mythead");
try {
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class demo1 {
public static void main(String[] args) {
Thread t1 = new Mythead();
t1.start();
while(true) {
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
代码解析:
-
创建一个线程类,线程类需要继承Thread类,并且重写Thread类的run方法
-
run方法相当于给线程分配任务.
-
通过向上转型创建出线程t1,(也就是在操作系统中创建对应的线程PCB,再通过链表进行串联,参与操作系统的调度.)
-
调用Thread类的start方法 启动线程
-
sellp控制打印不过与频繁
也就是说:run方法相当于给工人分配好任务,而start方法相当于工人开始工作.
如果我们去编译器执行以上死循环代码,我们可以发现,"hello thread"和"hello main"是交替打印的.
当我们不能认为他们是交替运行的!
-
每一个线程都是一个独立的执行流,main线程与t1线程都是独立运行的线程,两者是并发+并行的关系.
-
线程的执行顺序是由操作系统的调度器决定的.
由于操作系统的调度行为(保证每个线程的运行时间),我们在编写多线程的代码中,需要注意多线程代码的执行是"无序随机"的,
即便我们有一些方法可以控制多线程的执行顺序,但是在编写代码中仍然需要谨记操作系统的调度行为使得多线程代码的执行结果具有"无序随机"的特点.
简述Thread中run方法与start方法的区别
-
直接调用run方法,并没有创建出新的线程,而是在之前的线程中,执行run里的方法,
-
而使用start方法,则是创建新的线程,新的线程再去调用run方法,新线程与旧线程是并发执行的关系.
public class demo1 {
public static void main(String[] args) {
Thread t1 = new Mythead();
t1.start();
t1.run();
while(true) {
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
如上代码,这段代码只会打印hello thread,但是 是在两个线程中执行出来的结果,其中t1.start()为我们启动了线程t1,线程t1执行run方法打印hello thread,而t1.run()方法并没有创建出新的线程,所以便会在main线程中执行run方法的内容.
创建线程的几种方法:
-
创建一个类继承Thread,重写run方法
class Mythead extends Thread{ @Override public void run() { while(true) { System.out.println("hello mythead"); try { sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } }
-
创建一个类实现Runnable接口,重写run方法
class MyRunable implements Runnable { @Override public void run() { while(true){ System.out.println("hello thread"); } } } public class demo2 { public static void main(String[] args) { Runnable r1 = new MyRunable(); Thread t1 = new Thread(r1); } }
-
使用继承Thread类,但是使用匿名内部类实现.
创建匿名内部类,相当于Thread的子类.
public class demo3 { public static void main(String[] args) { Thread t1 = new Thread() { @Override public void run() { while(true){ System.out.println("hello thread"); } } }; t1.start(); } }
-
使用Runable,但是使用匿名内部类实现
public class demo4 { public static void main(String[] args) { Thread t = new Thread(new Runnable() { @Override public void run() { while (true) { System.out.println("hello mythread"); } } }); t.start(); } }
-
使用lambda表达式
public class demo5 { public static void main(String[] args) { Thread t1 = new Thread(()->{ while(true){ System.out.println("hello thread"); } }); t1.start(); } }
探讨串行执行与并行执行的执行时间
public class demo6 {
public static long COUNT = 20_0000_0000;
public static void concurnency(){
long beg = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < COUNT; i++) {
a++;
}
a = 0;
for (long i = 0; i < COUNT; i++) {
a++;
}
long end = System.currentTimeMillis();
System.out.println((end-beg)+"ms");
}
public static void concurnency1(){
long beg = System.currentTimeMillis();
Thread t1 = new Thread(()->{
int a = 0;
for (long i = 0; i < COUNT; i++) {
a++;
}
});
Thread t2 = new Thread(()->{
int a = 0;
for (long i = 0; i < COUNT; i++) {
a++;
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println((end-beg)+"ms");
}
public static void main(String[] args) {
concurnency();
concurnency1();
}
}
代码解析:
-
concurenency()方法中主要实现串行执行
-
concurenency1()方法主要实现并行执行
-
对于join()方法, 由于线程是并行执行的,所以t1,t2,mian线程是并发执行的,main线程可能会提前结束,所以我们希望main线程可以等待t1,t2线程,而使用join()方法这可以实现这个目的.
执行结果:
如果按照我们的理解: 结果2应该是结果1的恰好一半,事实却并非如此,如果我们多次运行,我们不难发现,每一次的结果2都会稍微大于结果1的一半.
原因:
-
创建线程也会有开销.
-
两个线程可能并不是纯并行执行,也有一部分时间可能是并发执行.
-
线程调度也会有开销.
多线程的使用场景:
-
CPU密集型场景.
使用多线程可以更好地利用CPU的多核计算资源.
-
IO密集场景
由于IO读写操作实在内存中读写,需要花费的时间很多,而且基本不需要CPU的参与,使用多线程避免CPU的过于闲置.
Thread类简单介绍:
构造方法:
-
Thread() -> 创建一个线程
-
Thread(Runnable target) -> 创建一个线程,并为线程分配任务
-
Thread(String name) -> 创建一个线程,并为线程命名
在操作系统中,线程是没有名字的,只有身份标识,而在Java中,为了便于调试,我们可以在JVM中为线程对对象命名.
补充内容: 对于线程的身份标识: 在操作系统内核中,线程有独属于自己的身份标识 在用户态线程库中,也有线程的身份标识 而在JVM中,线程也有身份标识. 三个标识虽然各不相同,但是目的都是一样的,都是为了起到区分的目的
-
Thread(Runnable target,String name) -> 创建一个线程,为线程分配任务并为线程命名.
获取线程的常见属性:
方法 | 说明 | 值得注意的 |
---|---|---|
getId() | 获取线程的身份标识 | 这里获取到的是线程在JVM中的标识 |
getName() | 获取线程的名称 | 也就是构造方法传入的name |
getState() | 获取线程的状态 | 这里获取的是线程在JVM中的状态 |
getPriority() | 获取线程的优先级 | 这里的优先级指的是线程执行优先级 |
isDaemon() | 判断当前线程是否为后台线程 | |
isAlive() | 判断当前线程是否存活 | |
isInterrupted() | 判断当前线程是否被打断 |
对于前台线程与后台线程的理解:
-
一个进程创建出来默认是一个前台线程,前台线程会阻止进程的结束,进程会保证所有的前台线程执行完再退出.
-
而进程并不会等待后台进程执行完才结束,对于进程来说,是否结束取决于前台线程.
-
我们可以通过 setDaemon() 来使得这个线程变为后台线程.
对于上述方法:我们获取到的都是一瞬间的状态.而不是持续的状态.
线程的常用方法(important)
start() - > (启动线程)
创建一个线程,并没有在操作系统内核中创建线程,
调用了start()方法,操作系统才会创建线程并开始真正执行任务.
interrupt() -> (中断线程)
对于中断线程,有两种方式
-
自定义一个标志位去控制线程.
public class demo7 { public static boolean isquit = false; public static void main(String[] args) { Thread t1 = new Thread(()->{ while (!isquit){ System.out.println("hello thread"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Thread线程即将结束"); isquit = true; System.out.println("线程结束"); } }
-
使用Thread类的标志位
public class demo8 { public static void main(String[] args) { Thread t = new Thread(()->{ while(!Thread.currentThread().isInterrupted()){ System.out.println("hello thread"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); t.start(); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } t.interrupt(); } }
理解Thread.currentThread().isInterrputed();
其中Thread.currentThread()是Thread中的一个静态方法,可以获取当前线程的实例.
Thread.currentThread().isInterrputed()这是内置的标志位,返回值为boolean,true表示线程将被中断
如果我们运行代码 大概率是以下抛异常死循环执行结果
这是因为 ,interrupt()的行为是分为两种:
-
如果线程没有处于阻塞状态,那么interrupt()就会修改内置标志位.
-
如果线程处于阻塞状态,那么interrupt()就会让导致线程阻塞的方法抛出一个异常InterruptedException(例如sleep())
值得注意的是,这里线程中的阻塞和非阻塞,在这段代码中,是随机的,只是因为阻塞的时间较长,所以容易触发第二种.
我们上面的结果就属于第二种
也正是由于这样的机制,我们可以自由地控制线程的退出行为了.在代码中的catch中可以增加线程即将退出时的善后操作.如:
-
直接退出: break;
-
稍后退出: 加上处理代码再break
-
不退出: 继续执行.
在操作系统原生的线程库中,中断的时候决定权是在调用者的,只要中断,线程立马就结束了,但是这样也带来了问题,很容易线程干一半的任务就这样终止掉了.
而JVM 中,将决定权转移了,这样就可以保证线程将任务干完再结束.
join() -> (线程等待)
方法 | 说明 |
---|---|
public void join() | 死等 |
public void join(long millis) | 最多等待millis ms |
public void join(long millis, int nanos) | 最多等待millis ms,但是可以设置更多的精度. |
如下 : 我们希望得到 t1 线程和 t2 线程运行的时间只和.
public class demo10 {
public static void main(String[] args) {
long beg = System.currentTimeMillis();
Thread t1 = new Thread(()->{
int num = 0;
for (int i = 0; i < 20_0000_0000; i++) {
num++;
}
System.out.println(num);
});
Thread t2 = new Thread(()->{
int num = 0;
for (int i = 0; i < 20_0000_0000; i++) {
num++;
}
System.out.println(num);
});
t1.start();
t2.start();
long end = System.currentTimeMillis();
System.out.println("耗费的时间 = "+ (end-beg));
}
}
我们可以很简单的就看出其中的弊端,由于线程 main和t1,t2是并行运行的,可能存在t1,t2线程执行一半,而main线程提前执行完的情况.
我们希望 main线程可以等待t1,t2线程运行结束再结束.
public class demo10 {
public static void main(String[] args) {
long beg = System.currentTimeMillis();
Thread t1 = new Thread(()->{
int num = 0;
for (int i = 0; i < 20_0000_0000; i++) {
num++;
}
System.out.println(num);
});
Thread t2 = new Thread(()->{
int num = 0;
for (int i = 0; i < 20_0000_0000; i++) {
num++;
}
System.out.println(num);
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("耗费的时间 = "+ (end-beg));
}
}
这里join的用法可以简单认为: 需要线程A去阻塞等待,线程A就来调用其他线程的join,这样,线程A 会等待其他线程结束再结束.
代码解析:
-
t1,t2线程启动后,main线程开始阻塞等待t1和t2线程
-
如果t1线程先结束,t2线程后结束.
当t1线程结束,t1.join()就执行完毕,当t2线程结束,t2.join也执行完毕.main线程结束阻塞状态.
-
如果t2线程先结束,t2线程后结束
t1线程结束,t1.join()就执行完毕,此时t2线程已经提前结束,t2.join也执行完毕.main线程结束阻塞状态.
控制进程的运行顺序:
public class demo9 {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
//也可能是其他复杂代码
System.out.println("hello thread1");
});
t1.start();
t1.join();
Thread t2 = new Thread(()->{
//也可能是其他复杂代码
System.out.println("hello thread2");
});
t2.start();
t2.join();
}
}
在一些场景下,需要某些线程先执行任务,再执行别的线程的任务.
sleep() -> (休眠线程)
sleep() 是Thread 类的一个静态方法.
主要作用是将线程从"就绪状态" 进入 " TIME_WAITING" 状态
其本质就是 线程本来是在操作系统的"就绪队列"中,准备操作系统调度参与CPU执行的,但是调用了sleep(),就会导致该线程在"就绪队列"中转移到"阻塞队列",当t ms后,sleep的时间到了,就会调度会"就绪队列".切记,这里只是把PCB放回就绪队列,而不是上CPU执行,具体多久上CPU执行,还是得看操作系统的调度.
线程的六个状态:
-
NEW :线程创建并且安排了任务 ,但是还没有开始运行
-
RUNNABLE: 线程开始执行或者即将开始执行
-
TIME_WAITING : 线程阻塞
-
BLOCKED: 线程阻塞
-
WAITING: 线程阻塞
-
TERMINATED : 线程完成任务结束.
线程之间的转换简易图:
线程安全问题
线程不安全引入样例
利用多线程 实现 数据a 自增10w次
public class demo11 {
public static int a = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
a++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
a++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(a);
}
}
很明显,上面的代码得不到我们想要的结果.
这是因为a++ 这一句代码 ,在操作系统中,会被分为3个机器指令:
-
从内存中读取数据到CPU (LOAD)
-
在CPU中完成加法操作 (ADD)
-
把寄存器的数据写入内存 (SAVE)
用时间线表示线程t1 与 线程 t2的执行情况:
每一次的执行,都可能有这六个指令的不同排序.(操作系统的调度算法),
例如图情况1,虽然我们自增了两次,但是读取的时候都是读的一样的数据,自增后写入的也就只是加一了.
进行加锁操作解决:(后文详细介绍synchronized)
public class demo11 {
public static int a = 0;
public static Object o1 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (o1) {
a++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (o1) {
a++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(a);
}
}
以上问题称为: 线程不安全.
造成线程不安全的原因:
-
操作系统的调度算法.
-
多个线程同时修改同一个变量.
-
有些操作并不是原子的.
例如操作系统中, " = “操作只对应一条机器指令,我们就可以视为原子的,但是” ++ "对应3条机器指令,则不是原子的.
-
内存可见性
什么是内存可见性:
如果是正常情况下,是没有问题的,但是程序运行的过程中,操作系统或者JVM,也可能是javac,都可能会对程序的执行过程进行优化,导致结果发生变化.
当发现读取多次后,数据并没有发生改变,就可能被JVM优化为以下情况:
被优化为不再从内存中重复读了,直接复用第一次从内存读到寄存器的数据即可,但是线程2突然写入了一个数据,但是线程1已经发生了优化,不再读取内存了,因此线程1感知不到数据的改变.
也就是说: 在优化的情况下,线程可能会感知不到内存数据的变化,称为内存可见性.
-
指令重排序
指令重排序也是操作系统/编译器/JVM 的优化.
通过调整代码的执行顺序来提高代码执行效率.
在单线程环境中,以下代码可以正常工作。但是,在多线程环境中,由于存在指令重排序,可能会出现 t1输出 0 的情况。原因在于,虽然 t2 设置了 result 的值,但是并没有保证执行顺序。具体来说,可能会先执行 ready=true 操作,然后再执行 result=42 操作。在这种情况下,t1 将读取到 result 的默认值 0。
public class demo12 { public static int result = 0; public static boolean ready = false; public static void main(String[] args) { Thread t1 = new Thread(()->{ while(!ready){ Thread.yield(); } System.out.println(result); }); Thread t2 = new Thread(()->{ result = 42; ready = true; }); t1.start(); t2.start(); } }
使用synchronized关键字来保证原子性
含义:
syncheonized翻译为"同步",这里的同步指的是"互斥".即两者不可同时做同一件事.
在多线程中,由于系统的并发执行,我们很难控制操作的原子性,引入synchronized就是为了将一系列操作打包成原子性.解决线程不安全.
对于同步的理解:
不同的场景同步的含义会不同.在多线程这里,同步意味着互斥.
而在IO场景和上下级调用的场景.则会有同步和异步的用法:
-
同步:调用者自己来负责获取到调用结果,相当于在麦当劳吃饭,我们点完菜后,需要自己去前台拿,前台客服不会为我们送上餐桌.
-
异步:调用者不负责获取调用结果,由被调用者将算好的结果直接主动的推送上来.相当于客服会将菜送上餐桌,无需我们去前台.
synchronized的执行流程
public class demo13 {
public static int count = 0;
public synchronized static void add(){
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 20; i++) {
add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 20; i++) {
add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
以上述代码为例:
count++ 可分为3条机器指令: load add save
synchronized将这3条指令的前后加上两条指令: lock (加锁) 与 unlock(释放锁)
在线程2执行到LOCK的时候就会发现线程1已经加上了锁,此时t2无法完成LOCK操作,就会阻塞等待(BLOCKED),阻塞等待到线程1把锁释放(UNLOCK),当线程1释放锁之后,线程2才能获取到锁.
这样就做到了将多个指令保证原子性,以解决线程不安全问题.
synchornized的使用与优缺点
针对实例对象加锁.
public class demo13 {
public static int count = 0;
public static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 20; i++) {
synchronized (object) {
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 20; i++) {
synchronized (object) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
在java中,任何一个对象都可以作为锁对象,(放在synchronized的括号中)这里我们创建一个实例对象作为锁对象.
每个对象在内存空间中都有一个特殊的区域叫对象头(JVM自带,包含对象的一些特殊信息).
这个时候,线程t1在执行锁synchronized的语句时,其他线程想执行被加锁的语句就会进入阻塞状态.
但是如果多个线程尝试对不同的对象进行加锁,则相互之间不会有互斥的效果,
针对类对象加锁
public class demo13 {
public static int count = 0;
public static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 20; i++) {
synchronized (demo13.class) {
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 20; i++) {
synchronized (demo13.class) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
这里相当于针对类对象进行加锁,在JVM中只有一个反射的时候,JVM加载类的时候就会读取.class文件,构造类对象在内存中.
也可以针对static方法加锁 : 一样是对类对象进行加锁
其实无论采用哪种方法,我们都需要明确锁对象,即明确哪一个对象来作为锁对象.
只有当两个线程针对同一个对象进行加锁时,才会产生互斥.(竞争)
如果两个线程针对不同的对线进行加锁,就不会产生互斥.(竞争)
java可以使用任意对象作为锁对象,C++,python则需要特殊的对象来作为锁对象,这也导致java的理解成本变高
但本质就是: 不同线程针对同一个对象就会发生竞争,而不同的线程针对不同的对象就不会发生竞争.
加锁操作为我们带来了线程安全,但同时,由于加锁操作也带来了线程阻塞与等待,也就会降低我们项目的性能.(基本可以与高性能诀别了)
使用volatile来解决编译器优化带来的线程不安全问题
在上文 “线程不安全的原因” 中,我们提到了 由于编译器对 机器指令的优化 ,使得多个线程在进行执行时会带来内存可见性和指令重排序的线程不安全问题.
对于编译器的优化,我们是很难进行预判的,也真是我们不好预判编译器的优化,所以我们在不确定的时候是必须加上volatile的.
而volatile操作就相当于禁止了编译器进行上述的优化:
-
被volatile修饰的变量会被加上"内存屏障" (二进制命令)
-
JVM 在读取这个变量时,就会因为"内存屏障"的存在,就知道每次都要读取这个内存的内容,而不是进行简单的优化.(虽然降低了速度,但是数据正确)
以下对于volatile修饰变量来禁止指令重排序进行扩展:(摘自大佬文章):
链接: 大佬文章速看
下面我们需要关注的一个代码案例:
如何解决这个代码所带来的指令重排序影响:
public class demo12 {
public static int result = 0;
public static boolean ready = false;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(!ready){
Thread.yield();
}
System.out.println(result);
});
Thread t2 = new Thread(()->{
result = 42;
ready = true;
});
t1.start();
t2.start();
}
}
从大佬的文章得知,有三种情况:
-
所有volatile变量之间相互序列化,即顺序固定,不会造成指令重排序的现象.
-
volatile变量读取后,非volatile变量读写操作不能重排序到volatile变量之前.非volatile变量读写操作可以排序到volatile变量之后.
-
volatile变量写操作之前,非volatile变量读写操作不能重排序到volatile变量之前,非volatile变量读写操作可以重排序到volatile变量之前.
为了稳妥起见,如果我们不想要代码中的变量发生指令重排序而导致的线程安全问题,可以直接给多个变量加上volatile变量修饰.
Java标准库中的线程安全类:
-
Vector (不推荐使用)
-
HashTable (不推荐使用)
-
ConcurrentHashMap
-
StringBuffer
-
String (只涉及读,不涉及写)
使用wait与notify更好地控制线程的执行顺序
方法:
方法 | 说明 | |
---|---|---|
wait() | 在没有通知之前,死等 | |
wait(long mills) | 等待 mills ms或者收到通知,再获取锁 | |
notify() | 唤醒对应锁的随机一个阻塞等待线程 | |
notifyAll() | 唤醒所有对应锁阻塞等待的线程 |
代码理解:
wait: 意为"等待",调用wait的线程会进入阻塞状态(WAITING)
notify: 意为"唤醒", 调用notify,可以把对应调用wait的线程唤醒.
import java.util.Scanner;
public class demo14 {
public static Object object = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(()->{
System.out.println("t1 开始执行");
System.out.println("t2 等待中");
synchronized (object){
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 被 唤醒成功");
});
Thread t2 = new Thread(()->{
Scanner scan = new Scanner(System.in);
System.out.println("输入数据 唤醒 t1");
//此处调用scan.next()也会进入阻塞状态
scan.next();
synchronized (object) {
object.notify();
//object.notifyAll();
}
});
t1.start();
t2.start();
}
}
代码解析:
-
wait()是Object类方法,所有对象都可以调用wait()方法.
-
wait()的内部执行流程:
-
释放锁: 但是想要释放锁的前提是获取到锁,所以wait()方法必须与synchronized一起使用,并且保证锁对象相同,只有这样才能获取到锁. 当线程调用了wait()方法, 就会释放掉锁,让其他线程执行.
-
等待通知: 等待notify()唤醒
-
当通知到达后,会尝试重新获取锁.
-
-
当一个或者多个线程等待时,notify()会随机唤醒其中一个等待的线程,而notifyAll()这是唤醒全部线程
-
当线程t1 执行, 线程t2 开始执行. t1执行到wait()会释放锁,锁被t2线程获取, t2调用notify()方法,唤醒线程t1, 线程t1 获取锁, 继续执行.
能有效避免"线程饿死"
什么是线程饿死:
在有些情况下,调度器的分配可能不均匀,就会导致有些线程会反复占有CPU,有些线程则始终无法在CPU上运行.
使用wait() 方法执行流程是会释放锁,所以可以让其他线程能够在CPU上执行.避免过度占有.
wait()和sleep()的区别:
虽然没啥可比性,但是两者的确都能让线程进入阻塞状态:
-
wait()方法可以让线程进入WAITING状态,而sleep()则是让线程进入TIME_WAITING状态
-
wait()方法是Object类方法,而sleep是Thread()的静态方法.
-
wait()需要搭配synchronized使用,而sleep()不需要.
多线程案例:
单例模式:
什么是单例模式:
单例模式是一种简单的设计模式,其主要实现的是一个程序中只能有这个类的一个实例.
在单例模式中,对象的实例化被限制,只能创建一个.
单例模式分为两大类:
饿汉模式:
程序启动,就立马创建实例:
//饿汉模式的实现:
class Singleton1{
// 程序启动就立马创建实例对象
public static Singleton1 singleton = new Singleton1();
//利用方法返回实例对象,其他线程可以通过方法来访问这个实例对象
public static Singleton1 getsingleton(){
return singleton;
}
//将构造方法设置为private ,这样就无法在其他线程中创建实例该类的实例对象.
private Singleton1(){
}
}
由于程序启动就会直接创建实例,所以多线程在获取对象只是读取这个实例,并不会造成线程安全问题,因此不需要进行加锁操作.
懒汉模式:
只有在线程第一次尝试获取该实例对象时,才创建这个实例对象.
class Singleton2{
volatile public static Singleton2 singleton2 = null;
public static Singleton2 getSingleton2(){
if(singleton2==null){
synchronized (Singleton2.class){
if(singleton2==null){
singleton2 = new Singleton2();
}
}
}
return singleton2;
}
private Singleton2(){
}
}
懒汉模式在多线程环境下.为了保证创建实例时的线程安全问题得到解决,我们的思考思路是:
如果这个sinleton2为空,则需要创建对象.
if(singleton2==null){
singleton2 = new Singleton2();
}
为了保证多个线程同时去实例化这个对象,我们进行加锁操作:
synchronized (Singleton2.class){
if(singleton2==null){
singleton2 = new Singleton2();
}
}
但是只要实例对象创建成功之后,就应该不会有线程安全问题的出现了,但是这里还是进行了加锁,这样频繁的加锁极度影响效率.
所以我们再加一层if判断:
if(singleton2==null){
synchronized (Singleton2.class){
if(singleton2==null){
singleton2 = new Singleton2();
}
}
}
对于为什么要给singleton2加volatile:
我们已经在上文中理解了volatile关键字可以防止JVM的优化来造成我们的代码出现bug,
那么如果不加volatile,程序一定会有问题? 答案是不一定的,我们无法得知JVM对我们上面代码优化的角度.
但是为了更加地保险,我们在单例模式中依然会选择加上volatile.
在网上对于加上volatile的原因这个问题的讨论,也没有明确的答案.
但是无非就是为了更加稳妥地实现单例模式,不会让JVM和操作系统的优化导致出现bug.
生产者消费者模型的介绍:
生产者消费者模型是我们在日常开发中常用的模型,他的应用是十分频繁的.
在没有使用生产者消费者模型的情况下:
使用A与B交互,如果我们再加入C与A进行交互,那么我们就需要去修改A中的代码逻辑,这样子的项目耦合度就十分地高.并且如果A挂了,可能也会直接将B也带走,这样耦合度高的同时,风险也高.
使用生产者消费者模型:
AB不再是直接进行交互,而是通过一个任务队列,A只需要考虑如何与任务队列发送数据,B也只需要考虑如何从任务队列中收取数据.我们再引入C,D,也不会影响我们A与任务队列的逻辑,我们只需要实现CD与任务队列进行交互即可.
这样就解决了耦合度高和AB直接交互带来的风险.
并且使用生产者消费者模型还可以做到"削峰填谷",提高整个系统的抗风险能力.
在外网向A提高大量数据需要发送给B和C时.
由于任务队列的存在不用担心过多的数据发送给服务器BC会导致BC承担不下去,我们就可以通过计算,扩大任务队列的容量来存储任务,让任务队列来承担压力,而BC只需要按照自己的处理速度来接收处理数据.
实现阻塞队列
在Java标准库中,为我们实现了阻塞队列BlockingQueue.
入队列为put(),出队列为take().
虽然阻塞队列BlockingQueue也可以使用普通队列的方法,但这些方法并没有实现阻塞的效果,所以在使用阻塞队列时还是得使用put()和take().
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class demo16 {
//阻塞队列的使用
public static void main(String[] args) {
BlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<>(10);
Thread t1 = new Thread(()->{
int n = 0;
while(true){
System.out.println("生产元素"+ n);
try {
blockingQueue.put(n);
n++;
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(()->{
while(true){
try {
int x = blockingQueue.take();
System.out.println("消费元素"+ x);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
}
}
实现阻塞队列:
class MyBlockingQueue{
//设定该任务队列容量的最大值为1000;
private int[] arr = new int[1000];
//队首元素位置
private int head = 0;
//队尾元素位置
private int tail = 0;
//记录队列中数据的个数
private volatile int size = 0;
public void put(int value) throws InterruptedException {
synchronized (this){
while(size==arr.length){
this.wait();
}
arr[tail] = value;
tail++;
if(tail==arr.length){
tail = 0;
}
size++;
this.notify();
}
}
public int take() throws InterruptedException {
int x = 0;
synchronized (this){
while (size==0){
this.wait();
}
x = arr[head];
head++;
if(head==arr.length){
head=0;
}
size--;
this.notify();
}
return x;
}
}
代码解析:
-
put()方法:
使用synchronized对方法进行加锁,做到基本的阻塞.
如果队列中的数据已经塞满了数组arr,那么我们就无法向队列中加入元素,我们就需要等待,使用wait()方法.
如果没有塞满数组arr,我们就可以向数组arr[]中加入元素,队尾标记变量++并且对size进行++,为了实现循环队列,如果加入数据后,队尾标记位置已经到达了数组的最后一个下标,那就将队尾标记变量重置为0. -
take()方法:
使用synchronized对方法进行加锁,做到基本的阻塞.
如果队列中没有元素,我们无法从任务队列中取出元素.我们需要等待其他线程put数据,使用wait()方法.
如果队列中有元素,我们向队列中取出元素,并且将队首标记变量往前移动,并且对size进行–.为了实现循环队列,如果取出数据后,队首标记位置已经到达了数组的最后一个下标,那就将队首标记变量重置为0.
-
互相使用notify()唤醒.
在put()方法中,如果arr[]元素已经塞满了队列,我们需要等待take()去取出元素才可以加入新的元素,所以take()中在取出元素时都需要尝试使用notify()唤醒.
在take()方法中,如果arr[]中没有元素,我们需要等待put()方法加入元素才可以取出元素,所以put()需要在加入元素后尝试使用notify()唤醒.
-
为什么使用while()进行判断,为了确保安全性,等待之前判断一次,唤醒之后再判断一次.
定时器
使用java标准库中的计时器:
import java.util.Timer;
import java.util.TimerTask;
public class demo17 {
public static void main(String[] args) throws InterruptedException {
//定义一个计时器
Timer timer = new Timer();
//向计时器中定义任务与多久后执行
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("这是一个任务");
}
},3000);
while(true){
System.out.println("main");
Thread.sleep(1000);
}
}
}
实现一个定时器:
-
需要定一个定时器类和任务类
-
定时器可以有多个任务,所以我们需要实现一个队列来存储任务.我们采用优先级阻塞队列存储.
任务类:
class MyTask implements Comparable<MyTask>{
private Runnable runnable;
private long time;
public MyTask(Runnable runnable,long after){
this.runnable = runnable;
this.time = after+System.currentTimeMillis();
}
public void run(){
runnable.run();
}
public long getTime(){
return time;
}
@Override
public int compareTo(MyTask o) {
return (int)(this.time-o.time);
}
}
定时器类:
class Mytimer{
private Object locker = new Object();
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
public void schedule(Runnable runnable,long after){
MyTask mytask = new MyTask(runnable, after);
synchronized (locker){
queue.put(mytask);
locker.notify();
}
}
public Mytimer(){
Thread t = new Thread(()-> {
//使用死循环来多次获取可能要执行的任务
while (true) {
//这里加入synchronized的目的是获取锁,来使用wait和notify
synchronized (locker) {
//如果队列为空,那么我们需要等待schedule加入任务后唤醒
while (queue.isEmpty()) {
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//如果队列不为空,那么取出元素,判断元素的时间是否已经到达,没到则重新回到队列并等待时间差值.
//元素时间到达则直接运行该任务.
try {
MyTask myTask = queue.take();
if (myTask.getTime() > System.currentTimeMillis()) {
queue.put(myTask);
//这里的wait设置为有等待时间的,因为如果有新的元素进来,可能这个元素的时间是在我们这个元素之前的,所以我们需要提前唤醒,由任务类来执行notify
//如果没有新的元素进来,那么会自动唤醒,执行下一次循环取出元素进行执行run.
locker.wait(myTask.getTime() - System.currentTimeMillis());
} else {
myTask.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
t.start();
}
}
线程池
在我们之前的学习中,为了减少进程的不断创建与销毁带来的大量时间资源的消耗(需要频繁的创建和销毁线程)我们引入了线程,但是在极端情况下,频繁地创建和销毁线程,也会导致效率过低的问题出现.
因此我们引入线程池的概念:
将创建好的线程放入池子中,当我们需要执行任务时,就将线程从线程池中拿出来,执行完任务再将线程们放回池子中去,这样就不需要频繁地创建和销毁线程来.
对于将线程拿出和放入池子,是纯用户态操作,效率是可以保证的
而创建和销毁线程,是内核态操作,具体效率还是由系统决定,我们无法保证.
在Java标准库中为我们提供了线程池:
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class demo19 {
public static void main(String[] args) {
ExecutorService Threadpoll = Executors.newFixedThreadPool(10);
for(int i = 0;i < 100;i++){
Threadpoll.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
}
对于线程池的创建,这里应用了"工厂模式"的设计模式,通过静态方法返回了实例对象.
对于Executors类,其实都是对线程池的原始类ThreadPollExecutor进行了多个new操作,以此来实现不同种类的线程池.
主要方法为submit(Runnable runnable),通过submit传入任务,线程池中的线程会执行任务.
实现线程池:
import java.util.concurrent.BlockingQueue;
class Threadpoll{
private BlockingQueue<Runnable> blockingQueue = new ArrayBlockingQueue<>(1000);
public void submit(Runnable runnable) throws InterruptedException {
blockingQueue.put(runnable);
}
public Threadpoll(int n){
for(int i = 0;i < n;i++){
Thread t = new Thread(()->{
while(!Thread.currentThread().isInterrupted()){
try {
Runnable runnable = blockingQueue.take();
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
});
}
}
}