【手把手带你学Java EE】多线程那些事,你了解了吗?
【手把手带你学Java EE】多线程那些事,你了解了吗?
- 线程
- 概念
- 意义
- 进程和线程的区别
- 面试题:谈谈进程和线程的区别和联系
- Java中的多线程编程
- 创建线程的方法
- 方法一
- 方法二
- 方法三 & 方法四
- 方法五
- 多线程的优势 - 提高效率
- Thread类及常见方法
- 构造方法
- Thread 的几个常见属性
- Thread类的常见方法
- start() 和 run()
- interrupted()
- join()
- currentThread()
- sleep()
- 线程的状态
- 线程安全
- 线程不安全的原因
- synchronized
- 特性
- 用法
- Java标准库中的线程安全类
- volatile
- 保证内存可见性
- 不保证原子性
- wait 和 notify
- wait() 方法
- notify() 方法
- notifyAll() 方法
- 面试题:wait() 和 sleep() 的对比
- 多线程案例
- 案例一:单例模式
- 饿汉模式
- 懒汉模式
- 案例二:阻塞式队列
- 概念
- 生产者消费者模型
- Java标准库中的阻塞队列
- 阻塞队列实现
- 案例三:定时器
- 概念
- Java标准库中的定时器
- 定时器的实现
- 案例四:线程池
- 概念
- Java标准库中的线程池
嗨~~上回咱们大致了解了计算机的工作原理,那么作为程序员,有一个东西不得不了解,那就是线程。
所以,今天我们就来看看关于线程的那些事情吧!
线程
概念
什么是线程?
正式的说法:线程就是一个“执行流”,每个线程之间可以按照顺序执行自己的代码。多个线程之间“同时”执行着多份代码
其实线程就是规模较小的进程,进程执行一个.exe文件里的全部代码,而线程执行的是一部分代码。
举个栗子:学校饭堂
学校的饭堂要供应一个学校学生的伙食,我们可以把做饭看成是一个进程,如果饭堂只有一个厨师,那那么多学生的饭怎么会供应得过来,所以科学的方式当然是需要有多个厨师。
饭堂供应伙食是一个大任务,而做饭又可以分为:炒菜、煮饭、做汤等等。每个部分都由不同的厨师负责,并且每个部分都有多个厨师同时做事。
这里的每个厨师就可以认为是一个线程,大家同时为提供伙食这件事而工作,其中把大任务分解成各个小任务(煮米饭、炒菜、做汤等等)交给不同的厨师来完成,大家可以按先后顺序完成,也可同时进行,这种情况就是多线程工作了。
所以线程的意义相信大家很容易就能明白。
意义
上回我们介绍了进程的一些属性,而这些属性的存在主要是为了能够实现进程的调度。
而现在的操作系统,一般都是“多任务操作系统”,一个系统在同一时间,能够执行很多任务,比如打开电脑,可以同时在电脑上运行QQ、微信等软件,还能同时打开腾讯会议上网课摸鱼 、打开这篇文章偷偷学习新知识,这就是一种多任务操作。而系统在多个软件中进行切换操作就类似于“进程调度”。
而这种多任务同时运行,就依赖于“并行”和“并发”编程这两个事情。
并行:微观上,多个CPU核心同时执行多个任务的代码
并发:微观上,单个CPU核心,多个任务切换执行,只要切换的足够快,我们在宏观上看起来就像是过个任务同时执行。这就像时间管理大师,一个人同时和多个对象聊天,但是只要消息回复得够快,对方就觉得好像你好像只是在跟Ta一个人聊天。
注意:这里的并行和并发是微观上的情况,在宏观上,我们是区分不了到底系统的并行还是并发状态,这些都是操作系统自行调度的结果,多个任务同时尽进行时,有个任务可能是并行关系,有的任务则可能是并发关系。
所以我们再宏观上区分不了并行和并发,而通常只有在研究操作系统的进程调度的时候会对它们进行区分,平时我们通常将题目统称为“并发”。而实现“并发”就需要并发编程。
可能有的同学会问,那为什么不是多进程而是多线程呢?
其实通过多进程,我们也是完全可以实现并发编程的。
但是多进程意味着我们要频繁的创建或者销毁进程,而创建进程就得分配资源,比如申请空间,打开文件;同理销毁进程也需要释放资源。这就导致了进程的创建和销毁的成本是比较高的,如果我们频繁地调度进程,也会需要比较高的成本。
所以为了解决这个问题,我们可以有两个思路:
- 才通过进程池来避免过于频繁地创建和销毁进程,需要使用的时候就从进程池取进程,不使用的时候就放到进程池中。
然而进程池虽然能有效地提高效率,但是进程池里闲置的进程,也会消耗系统的资源,而当进程池里有很多线程的时候,对系统资源的消耗就会变得很大。- 使用线程来实现并发编程。
与进程相比,线程更轻量。每个进程可以执行一个任务,每个线程也可以执行一个任务/一段代码,也能够实现并发编程。并且相比进程,线程创建和销毁的成本要低很多,所以调度线程的成本也会相对低很多。
(因此,在Linux上也把线程称为轻量级进程(LWP light weight process))
进程和线程的区别
那么为什么线程比进程更轻量呢?
我们可以思考一下,为什么进程重呢?它重在了哪里??
答:进程重在资源的申请和释放。
我们前面已经学过进程的属性,可以看出进程对资源的申请释放并不是一件特别高效的事情。而资源的申请就好比我们去储藏室找东西,总是要话费一段力气,才能把东西找出来。
而**线程是包含在进程重的,一个进程重的多个线程,是共用同一份资源(同一份内存+文件)的。**只是在创建进程的第一个线程的时候,由于要分配资源,成本相对比较高,但是后续再创建这个进程的其他线程,其成本就不那么高了,因为此时已经不需要再分配资源了。
而这就像奶奶想要织毛衣,她去让我去储藏室找毛线,我在储藏室倒腾了一番终于把毛线找出来了,如果奶奶用完了之后,让我们放回去,那下次奶奶或者妈妈要用的时候,我就得再去储藏室拿,我就会感觉麻烦,而如果我没有放回去,奶奶用完了之后,妈妈想取一些毛线去织个围巾,就可以直接拿来用,就不需要我再跑去储藏室拿了。
更形象地,我们可以把进程比作一个工厂,假设这个工程有一些生产任务,要生产2W把凳子,这时候如果老板想提高效率,有两种做法:
- 再建一个工厂,两个工厂同时进行工作,每个工厂生产1W把。
- 爱是同一个工厂,在工厂里多加一条生产线,两条生产线同时投入生产,每条生产线生产1W把。
可以看到两种方式都能提高生产效率,并且最终话费的时间相差不大,但是创建工厂的成本相比于创建生产线的成本就会高很多了。这里的工厂在操作系统中就是进程,而生产线则是线程。相信大家应该能更好地明白进程和线程了吧!
但是请注意,是否多加一些线程,效率就会进一步提高呢?
会,但是也不一定会。因为资源始终是有限的,当线程增加到了一定的程度,线程之间就可能要竞争同一个资源,这时候,整体的速度也就受到了限制。就像工厂中的生产线,虽然效率提高了,但是工厂中的电力等等资源可能是有限的,如果电力只足够提供一条生产线,则另一条生产线也无法很好地投入生产,效率也就难以提高了。
所以,人们在线程的基础上,又引入了“线程池”和“协程”的概念。而关于线程池的内容将会在后面进行介绍。
面试题:谈谈进程和线程的区别和联系
- 进程包含线程,一个进程里可以有一个线程,也可以有多个线程
- 进程和线程都是能处理并发编程问题场景,但是进程在频繁创建和销毁中,开销更高,线程的开销更低,因为线程更轻量。
- 进程是系统分配资源的基本单位,线程是系统调度执行的基本单位。
- 进程之间是相互独立的,各自有各自的虚拟地址空间,同一个进程内部的多个线程之间,共同使用一份内存空间和文件资源。一个进程出问题不会影响其他的进程,但是一个线程出问题就可能影响整个进程和该进程的其他线程。
Java中的多线程编程
线程是操作系统中的概念,操作系统的内核实现了线程这样的机制,并且给用户层提供了一些API供用户使用(例如Linux中的Phread类)。
注意:API大意我提供好了工具以及接口,你只要拿去使用就行了,不需要关心其内部的实现
Java在的标准库,为程序员提供了一个Thread类,用来表示或操作线程。这个Thread类就可以视为是对操作系统提供的API(C语言风格)进行了进一步的抽象和封装产生的类。
这里我们通过Thread类创建出来的实例,就是一个线程,它和操作系统中的线程是一一对应的关系。
创建线程的方法
通过Thread类创建线程,写法可以有很多种,下面给大家介绍五种常见的创建线程的方法。
方法一
其中最简单的做法就是创建子类,继承自Thread类,并重写其中的run()方法。
一个最基本的创建线程的方法:
class MyThread extends Thread{@Overridepublic void run() {//描述了这个线程内部要执行的任务System.out.println("hello thread");}
}public class TestDemo1 {public static void main(String[] args) {Thread t = new MyThread();//创建出现一个线程的实例t.start();//调用start方法后,线程才开始执行}
}
这个最简单的多线程程序实际上就是创建一个继承自Thread类的子类,并重写run方法。
其中run方法就秒回了这个线程需要完成的任务,我们可以以此创建多个线程,每个线程都是并发执行的(即各自执行自己的代码)因此我们就通过run方法告诉线程要执行哪些代码。(即run方法中的代码是由我们创建的线程执行的)
需要注意的是,当我们创建了一个线程的实例后,需要调用start方法,线程才会开始执行。
这个过程相当于我喊了我的家人过来帮我搬家,但是只是提前安排好了这个事情,实际上还没开始搬,需要我调用start方法了,即告诉家人们,说可以开始搬家了,大家才会开始干活。
上面的代码只是创建了一个线程,下面我们通过循环创建多个线程:
class MyThread2 extends Thread{@Overridepublic void run() {while (true)//让线程无限循环打印{System.out.println("hello thread");try {//强制让线程休眠1秒(进入阻塞状态),单位msThread.sleep(1000);} catch (InterruptedException e) {//线程被强制中断引起的异常e.printStackTrace();}}}
}public class Demo2 {public static void main(String[] args) {Thread t = new MyThread2();t.start();}
}
上面的代码中,线程的任务是每个1秒打印一个"hello thread",并且如果进程不终止,线程将一直打印下去。
其中Thread.sleep()是让线程休眠的方法,后面会介绍。
调用Thread.sleep()方法可能会抛出异常,因此需要使用try-catch对异常进行处理。
其中 InterruptedException 是多线程中最常见的一个异常,表示线程被强制中断了。为什么会抛这个异常呢?因为线程在休眠的过程中,是有可能被打断(提前唤醒的)。
我们执行上面的代码,可以看到如下的结果:
程序每个1秒就在控制台上打印一个"hello thread"。
但是我们现在还看不出来多个线程的并发执行,所以我们再对上面的程序进行一些改动:
class MyThread2 extends Thread{@Overridepublic void run() {while (true)//让线程无限循环打印{System.out.println("hello thread");try {//强制让线程休眠1秒(进入阻塞状态),单位msThread.sleep(1000);} catch (InterruptedException e) {//线程被强制中断引起的异常e.printStackTrace();}}}
}public class Demo2 {public static void main(String[] args) {Thread t = new MyThread2();t.start();while (true){System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
}
在一个进程中,至少会有一个线程。
实际上,在我们平时写的代码中,main方法也是一个线程。而在上面的代码中,我们手动创建的 t 线程和程序自动创建的main线程之间就是一个并发执行的关系。
注:这里的并发关系指的是宏观上的同时执行,因为前面我们已经讲过,真正的执行是并行还是并发,是取决于操作系统内部的调度的,我们在宏观上是无法区分的。
现在我们运行上面的程序,看看会有什么效果:
这两个线程都是打印一条结果就休眠1秒,但是1秒时间到了之后,是先打印哪个呢?我们从控制台的结果上可以看到,这个问题的答案是不确定的。
实际上操作系统内部对于线程之间的调度顺序,在宏观上可以认为是随机的。而这种随机调度的过程也可以称为“抢占式执行”。即哪个线程先抢到了CPU的资源,则哪个线程就先执行,而这其中包含许多不确定的因素,因此就认为是随机的。
这时候,有的同学可能会问,那如果我们让一个线程 sleep(1000) 另一个 sleep(1001) 那是不是就可以避免这种情况了呢?
这里要提的一点是,sleep(1000)并不是线程休眠了1000ms 之后就可以上CPU继续执行,而是说在这1000ms 之内不能使用CPU的资源,因此这里写10001 与 1000 并不能达到我们想要的效果,有可能休眠 1001ms 休眠结束就能立即拿到CPU的资源继续执行,而休眠 1000ms 的线程休眠结束后可能抢不到CPU资源,因此这个事情并不能这么简单就讲它们分了先后,因为其内部是有很多不确定因素影响的。
方法二
上面我们介绍了创建线程最基本的方法,那么还有一种方法是我们自己创建一个类,这个类实现 Runnable 了接口,再创建 Runable 的实例传给 Thread 类。
下面我们看代码:
class MyRunnable implements Runnable{//通过Runnable来描述任务的内容@Overridepublic void run() {System.out.println("hello");}
}public class Demo3 {public static void main(String[] args) {//把描述到的任务通过Runnable实例交给Thread的实例Thread t = new Thread(new MyRunnable());t.start();}
}
方法三 & 方法四
方法三和方法四实际上分别是方法一和方法二的翻版,其机制的通过创建一个匿名内部类的方法来创建线程。
下面看代码:
public class Demo4 {public static void main(String[] args) {Thread t1 = new Thread() {//创建出一个匿名内部类,并重写run方法, 同时创建出这个匿名内部类的实例@Overridepublic void run() {System.out.println("hello thread");}};t1.start();//仍然使用start方法开始线程Thread t2 = new Thread(new Runnable() {//针对Runnable创建的匿名内部类,并创建出其实例然后传给Thread类的构造方法@Overridepublic void run() {System.out.println("hello runnable");}});t2.start();}
}
以上两个写法都是正确的,但是通常 Runnable 的方法更被推荐,因为它能够做到让线程和线程执行的任务更好地区分开来,实际上就是更遵循一种代码“高内聚、低耦合”的原则。
我们可以看到,这里的 Runnable 实际上只是单纯地描述了一个任务,至于这个任务是给谁来执行,Runnable 本身并不需要关心。
方法五
方法五实际上是上述第四中写法的变式,即使用了 lambda 表达式来简化代码,实际上它的意思同方法四相同,只是使用了 lambda 表达式代替了 Runnable 的写法。
Thread t3 = new Thread(()->{//lambda表达式System.out.println("hello lambda");});t3.start();
多线程的优势 - 提高效率
我们使用多线程的初衷就是一个任务太复杂了,或者太繁琐了,于是就希望通过把任务分发给其他的执行流来同时完成,以提高效率。
那么多线程到低能提高多少效率呢?
我们通过一个简单的例子来看一看。
现在有两个整数变量,分别对这两个变量自增10亿次,一个使用一个线程,一个使用两个线程,看看两次执行所花费的时间。
首先看一个线程执行所需要的时间
public class Demo5 {//串行private static final long count = 10_0000_0000;//在写一个比较长的整数常量时,可以通过—来进行分隔public static void serial() {//记录程序执行时间long beg = System.currentTimeMillis();long a = 0;for (int i = 0; i < count; i++) {a++;}long b = 0;for (int i = 0; i < count; i++) {b++;}long end = System.currentTimeMillis();System.out.println("执行时间:" + (end - beg) + "ms");}public static void main(String[] args) {serial();}
}
执行结果如下图:
下面我们再用两个线程来实现:
public class Demo6 {public static final long count = 10_0000_0000;public static void concurrency() throws InterruptedException {long beg = System.currentTimeMillis();Thread t1 = new Thread(()->{long a = 0;for (int i = 0; i < count; i++) {a++;}});t1.start();Thread t2 = new Thread(()->{long b = 0;for (int i = 0; i < count; i++) {b++;}});t2.start();//此处不能直接计算时间,因为这里是main线程执行的,时间也是由main线程执行的//而自增操作是由线程执行的,如果代码走到这里就直接记录时间是不准确的//因此需要使用join方法让main线程等待t1和t2都执行完之后再往下走:记录时间t1.join();//让main线程等待t1执行完 - join方法可能抛异常,此处直接使用throws处理了t2.join();//让main线程等待t2执行完long end = System.currentTimeMillis();System.out.println("执行时间:" + (end - beg) + "ms");}public static void main(String[] args) throws InterruptedException {concurrency();}
}
此时我们再看看运行结果:
从以上的两个执行结果可以看到,串行(只有一个线程)执行的时候,执行时间为700+ms,两个线程并发执行的时候,执行时间为400+ms,效率可以说是大大提高了!所以使用多线程在处理一些任务量比较大的情况下是能够有效提升效率的。
但是要注意,并不是使用了两个线程就一定能比一个线程提高50%的效率,因为前面我们说过,并发实际上是宏观的并发,在操作系统内部,这两个线程到底是并发执行,还是并行执行,是不确定的(通常两者都有),而只有当真正并行执行的时候,效率才会有显著提升~
同时要说明的是,多线程并不是任何情况下都能提升效率,因为创建线程这件事本身也是有开销的,如果我们计算的count太小,这时候创建线程就变成一个相对较大的开销了。
这就好像我们从家步行到学校只需要5分钟的路程,可是我却选择开车到学校,那我到停车场取车的时间可能就不止5分钟了,如果我还开车到学校去,那还不如直接走路去学校节省时间。
所以假设我们创建两个线程就需要50ms的时间,而完成任务本身只需要20ms的时间,那这时候再使用多线程就会花费更多的时间了~
所以,多线程并不是万能良药,具体使用还是需要看使用场景。总的来说,多线程适合于CPU密集型的程序,当程序需要进行大量的计算时,使用多线程就可以更充分地利用多核资源。
Thread类及常见方法
Thread 类是JVM用来管理线程的一个类,每个线程都有一个唯一的Thread对象与之关联。
从上面的例子来看,每个执行流(线程),也需要有一个对象来描述,而Thread类的对象就是用来描述一个线程执行流的,JVM 会将这些Thread对象组织起来,用于线程的调度和管理。
下面我们就来看看Thread类中有哪些常见的方法。
构造方法
下面给出几个Thread类的常见构造方法
方法 | 说明 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并为之命名 |
Thread(ThreadGroup group,Runnable target) | 线程可以被用来分组管理,分好的组即为线程组(了解即可) |
前面两个线程的构造方法在上面创建线程的章节中已经进行过介绍。
这里主要说明一下为线程命名。
这个实际上是为我们自己创建的线程起一个名字,这个名字本身并不影响线程的执行,只是起到方便程序员调试的作用。
当我们要调试线程的时候,可以根据线程的名字来对线程进行区分。
Thread 的几个常见属性
属性 | 说明 | 获取方法 |
---|---|---|
ID | 线程的唯一标识 | getId() |
名称 | 同于各种调试(在调试工具中会用到) | getName() |
状态 | 表示线程当前所处的情况 | getState() |
优先级 | 优先级高的线程理论上更容易被调度到 | getPriority() |
是否后台线程 | JVM会在一个进程的所有非后台线程结束后才结束运行,如果非后台线程,则进程不会等待线程执行完毕,就直接关闭了 | isDeamon() |
是否存活 | 可简单理解为run方法是否执行完毕 | isAlive() |
是否被中断 | 线程执行过程中被其他事务中断 | isInterrupted() |
- 关于名字
Java中,我们可以使用 jconsole 来观察线程的名字。
jconsole 的 jdk 自带的一个测试工具
首先在找到文件所在路径:(以下是我电脑中的路径)
C:\Program Files\Java\jdk1.8.0_192\bin
当我们我们的代码运行起来之后,打开 jconsole.exe 文件。
选择我们写的代码,点击连接。
进去之后可以看到如下界面
转到线程页面,我们可以看到程序中的main线程:
再往下找,可以看到我们创建的线程,我们没有给它命名,此时线程的名字为Thread-0。
此外,还可以看到进程中还有许多其他的线程,这是因为java进程启动之后,进程中不只有我们自己创建的线程,还有一些由JVM自己的创建的线程,它们分别用来进行一些其他的工作。
- 关于后台线程
主要说明两点:
如果线程是后台线程,就不影响进程退出;反之(前台线程)则会影响进程退出。
那么这个影响是什么影响呢?
其实就是main方法如果比线程先执行完,那么整个进程也得等其他前台线程执行完才能退出。而如果是后台线程,则main方法执行完毕,整个线程就可以直接退出,如果后台的线程还没执行完,就会被强行终止。我们一般创建的线程默认都是前台线程。
- 关于“存活”
存活即线程的任务还没完成,还在操作系统中运行。
正如前面说的,一个线程对象创建之后,如果不调用start方法启动线程,则系统中是没有对应的线程在运行的;而当线程执行完run方法后,系统中的线程就销毁了,但是我们创建的线程对象可能还存在,这时候我们就可以通过isALive方法来判断当前系统的线程运行的情况。
如果是在调用start之后,run方法执行完之前,isALive就返回true;
而如果是在调用start之前,或者run方法执行完之后,isALive就返回false。
Thread类的常见方法
start() 和 run()
在创建线程的第一个方法中,我们就用start方法进行了演示,但是如果我们将start方法换成run方法,再次运行,就会发现结果程序仍然是打印一串"hello thread"。
class MyThread extends Thread{@Overridepublic void run() {System.out.println("hello thread");}
}public class TestDemo1 {public static void main(String[] args) {Thread t = new MyThread();//t.start();t.run();}
}
这样看来,start 方法和 run 方法貌似没有什么区别。
但是!其实他们的区别非常非常大!!
(首先说明:start方法和run方法的区别是面试中一个经典的问题。)
要回答这个问题,我们就要搞清楚这两个方法分别是做什么的。
t.start();//描述了任务的内容
t.run();//决定了系统中是否真的创建出线程
其实在前面已经介绍过,run方法就是用来描述任务内容的,即告诉线程要完成什么任务,但仅仅只是告诉了它。
而start则是一个特殊的方法,调用这个方法之后,程序内部会在系统中创建线程,线程才真正存在于操作系统中并跑起来。
在下面这段代码中,我们分别用 run 方法和 start 方法运行,就能感受到很大的不同:
public static void main(String[] args) {Thread t = new Thread(()->{while (true){System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();//t.run();while (true){System.out.println("hello main");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}
如果我们调用的是 start 方法,则程序会创建一个线程,这个线程和 main 线程是并发执行的,因此可以看到控制台分别打印出 “hello thread” 和 “hello main” 两句话:
而如果我们把这里的 start 方法改成 run 方法,程序是没有创建新的线程的,这里的run方法仍然是由main这个线程来执行,它会在执行完 run 方法中的代码之后,才会进入到下面的循环中。
interrupted()
如果我们想让一个线程停下来,应该如何做呢?
一般地,一个线程停下来的关键,就是要让线程对应的 run 方法执行完。特别地,对于 main 线程来说,则是 main 方法执行完了才停下来。
那么我们怎么使线程结束呢?
- 方法一:手动设置一个标志位,通过标志位来控制线程是否要执行结束。
public class Demo8 {public static boolean isQuit = false;//标志位 - 用于控制线程是否继续执行public static void main(String[] args) {Thread t = new Thread(()->{while (!isQuit){System.out.println("hello thread");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();//main线程等待5秒后,将标志位置为true,则t线程退出循环,任务执行结束,线程t终止try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}isQuit = true;System.out.println("终止t线程!");}
}
从上面例子中可以看到,我们可以通过在其他线程中控制这个标志位,进而控制这个线程是否继续执行。
其背后的原理在于多个线程使用的是同一个虚拟地址空间,因此此处main线程修改 isQuit 和 t 线程判定的 isQuit,是同一个值。
上面的方式,是通过程序员手动设置一个标志位来实现线程的终止,但是这种做法并不严谨,所以下面我们来看一下相对更严谨的方法二。
- 方法二:使用 Thread 中内置的标志位来进行判定。
Thread 内部包含了一个boolean类型的变量作为线程是否被中断的标记。
Thread.interrupted();//静态方法
Thread.currentThread.isInterrupted()//实例方法,其中currentThread用于获取当先线程的实例(更推荐使用)
首先我们可以通过上述两个方法来获取标志位,其他线程中通过 t.Interrupt() 来中断线程。
public class Demo9 {public static void main(String[] args) {Thread t = new Thread(()->{while (!Thread.currentThread().isInterrupted()){//使用Thread内部的标志位System.out.println("hello");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}});t.start();try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}t.interrupt();//中断线程}
}
但是执行程序之后,我们会看到似乎结果和我们想象的并不一样:
程序抛出了一个异常,并且异常抛出后,并没有停止下来,而是继续往下执行,这是为什么呢?
当线程 t 启动后,他有两种状态,一种在执行输出语句(就绪状态),一种则是处于休眠状态(阻塞状态)。
当main线程调用 Interrupt 方法时,如果 t 处于就绪状态时,则设置线程的标志位为 true,而如果 t 正处于阻塞状态时,线程就会触发一个 InterruptedException (线程中断异常),此时线程就会从阻塞状态被唤醒。
上面的代码中,线程 t 触发异常后,就进入了 catch 语句,在 catch 中打印出了异常位置,然后继续运行。
而一个线程打印一段的代码的时间是很短的,相比之下,线程更多时间是处在阻塞状态,因此,当我们直接调用 Interrupted的时候,是很容易就会触发异常的。
那么这个问题应该如何解决呢?
其实很简单,我们只需要在异常处理机制中添加一步就行了。
这一步就是 break; 当线程触发异常之后,立即退出循环,这样问题就迎刃而解了。
但是有时候让线程立即停下来可能不太好,它还需要完成一些收尾工作,所以我们还可以在 break 之前让线程做一些收尾工作,或者把收尾工作放到 finally 语句块中。
public class Demo9 {public static void main(String[] args) {Thread t = new Thread(()->{while (!Thread.currentThread().isInterrupted()){System.out.println("hello");try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();System.out.println("进行一些收尾工作");break;}}});t.start();try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}t.interrupt();}
}
运行程序,这次程序就被正常终止了,并且在终止之前完成了线程的收尾工作。
另外,由于一个代码中的线程是可以后多个的,而 Thread.interrupted() 方法中标志位是一个 static 成员,这就意味着一个程序中只有一个标志位,所以我们通过这个来对多个线程进行控制的时候,是很容易出问题的。
而 Thread.currentThread.isInterrupted() 是一个普通的成员方法,其标志位只对应一个线程的实例对象,每个实例都有自己的标志位,这样使用起来就不容易引起BUG,所以一把更推荐使用这个方法。
也因此,Thread.interrupted() 方法需要清楚标志位,而 Thread.currentThread.isInterrupted() 不需要。
join()
下面的方法与线程等待有关~
我们知道,线程之间的调度顺序是不确定的,这个顺序可以视为是“无序的、随机的”。
但是有时候我们的程序需要对线程之间的顺序进行一些控制,这时候就需要使用一种控制线程顺序的手段,而线程等待就是其中一种。
注意:这里我们说的线程等待,主要是控制线程结束的先后顺序~
比如:A是班上的课代表,B是班上的同学,每天早上到了学校,A就开始收作业,如果B同学这时候还没到学校(可能还在家里赶作业),那A就等一下B,等到B到了学校,把作业交给A之后,A再把作业送到老师办公室去。
这里举的例子实际上是等待的意思,线程之间谁先完成任务是不确定的。假设A和B是两个线程,他们有各自的任务,但是A线程需要等B完成了任务,才能继续完成自己的任务,这时候就需要用到 join() 方法了。
即A线程先到了学校,但是B线程还在赶作业的路上,这时候A线程把其他同学的作业都收好了之后,调用 B.join() 方法进入阻塞状态,等到B写完作业并且来到学校后,A再回复到就绪状态,拿到B的作业,然后把全班同学的作业一起送到老师办公室。
注意:这里是A等待B,所以要在A中调用B对象的 join() 方法,大家千万不要搞混了噢~
public class Demo10 {public static void main(String[] args) {Thread B = new Thread(()->{System.out.println("B还在赶作业,等一等马上好!");try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("B终于赶完作业了,赶紧交给课代表A~");});B.start();Thread A = new Thread(()->{System.out.println("就差B的作业了……");try {B.join();//等待B完成作业} catch (InterruptedException e) {e.printStackTrace();}System.out.println("A收齐作业,送到老师办公区了~");});A.start();}
}
运行程序,结果如下:
join 方法在默认情况下,会一直等待B线程结束,但是课代表A必须在每天上课之前把同学们的作业交到老师那里去,如果B完成得太慢,A还一直苦等,就超出了规定的交作业时间。
所以,这里我们就可以使用 join 方法的另外一个版本,给 join 方法添加一个时间,如果A线程等待的时间超过了这个时间,那么A线长就认为等太久了,不能再等了,他就直接进行下一步,把作业交给老师了。
public class Demo11 {public static void main(String[] args) {Thread B = new Thread(()->{System.out.println("B还在赶作业,等一等马上好!");try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("B终于赶完作业了……");});B.start();Thread A = new Thread(()->{System.out.println("就差B的作业了……");try {B.join(2000);//等待B完成作业,只等2秒,再晚就来不及交给老师了} catch (InterruptedException e) {e.printStackTrace();}if (B.isAlive()){//两秒之后,如果B还在赶作业,那A也不再等了,直接把作业交给老师了System.out.println("A不能再等了,现在就把作业交给老师了。");}else {//如果B已经写完作业了System.out.println("A收齐作业了,赶紧交给老师");}});A.start();}
}
这里显然B写得太慢,A已经等不了了,直接把作业交给老师了~
上面关于多线程的效率中,也使用了 join() ,也是同样的道理(即让main线程等待其他线程都执行完毕了再计时)。
currentThread()
这个方法主要用于获取当前线程的引用(Thread 实例的引用),假设是A调用这个方法,则获取到的就是A线程的实例。
下面直接看例子~~
public class Demo12 {public static void main(String[] args) {//通过继承Thread类创建的实例//可以通过Thread.currentThread()方法获得当前实例//也可以通过this直接拿到当前的实例Thread t1 = new Thread(){@Overridepublic void run(){System.out.println(Thread.currentThread().getName());System.out.println(this.getName());}};//通过传递Runnable创建的线程实例//只能通过Thread.currentThread()方法获得当前实例//不能通过this直接拿到当前的实例(因为这里的this指向的不是Thread类型了,而是一个单纯的任务)Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println(Thread.currentThread().getName());
// System.out.println(this.getName());//getName本身不是Runnable的方法,不能直接使用this调用}});//使用lambda表达式的效果与Runnable相同,不再赘述Thread t3 = new Thread(()->{System.out.println(Thread.currentThread().getName());
// System.out.println(this.getName());});}}
sleep()
这个方法我们在前面已经调用过很多次了~
那么线程休眠到底是在做什么事情呢?
关于这个问题的回答需要我们回到上一节中关于进程的介绍:
进程是怎么在系统中描述的?
用PCB描述的。
进程是怎么组织的?
使用双向链表。
上面这个说法针对的是只有一个线程的进程的情况。
那么如果一个进程中有多个线程呢?
这时候每个线程都有一个PCB,即一个进程对应的是一组PCB。
PCB中有一个 tgroupId 字段,这个 id 相当于进程的 id,同一个进程中的若干个线程的 tgroupId 字段是相同的。
这里可能有同学会问,PCB不是 process control block 吗?
这是进程控制块,它和线程有什么关系呢?
其实,在 linux 内核中,系统是不区分进程和线程的,它把把线程称为轻量级进程。
下面我们通过举例来对此进行说明。
假设下面是系统中的PCB的就绪队列和阻塞队列:
当其中的某一个线程调用sleep的时候,这个线程对应的PCB就会进入到阻塞队列中。
系统只会调度出在就绪状态的线程上CPU运行,而处于阻塞状态中的线程什么都做不了……
当线程睡眠的时间到了,即sleep中的时间到了,PCB才会从阻塞队列中回到就绪队列,这时候PCB才有机会到CPU中运行。
所以我们需要理解的一点是,调用 sleep 只能保证线程实际休眠的时间是大于等于参数设置的休眠时间的,但是并不能保证睡眠时间一过,线程就能立即到CPU上执行任务。
我们可以通过记录线程休眠之前的时间和恢复运行的时间,来看看线程实际休眠了多长时间。
public class Demo13 {public static void main(String[] args) {Thread t = new Thread(()->{long stat = System.currentTimeMillis();try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}long end = System.currentTimeMillis();System.out.println(end - stat);});t.start();}
}
运行程序:
可以看到实际线程等待的时间是超过了3000ms的。
再次运行:
再次运行:
可以看到每次线程等待的时间都是不一样的,但是每次等待的时间都是大于等于3000ms的。
线程的状态
前面我们所说的就绪和阻塞状态,针对的是系统层面上的线程的状态。
在 Java 的 Thread 类中,对于线程的状态又有了进一步的细化。
首先我们可以通过Thread.State来看一看Thread类中的线程有哪些状态。
public class Demo14 {//这里Thread.State是一个枚举类型public static void main(String[] args) {for (Thread.State s : Thread.State.values()) {System.out.println(s);}}
}
在运行结果中我们可以看到Thread类中的所有可能的线程状态:
下面我们就来分别介绍一下这些状态。
NEW
首先是NEW,即 Thread 对象创建好了,工作安排好了,但是还没调用 start ,没开始工作。TERMINATED
工作完成了。即操作系统中的线程已经执行完毕,销毁了,但是Thread对象还在时的状态。RUNNABLE
就绪状态(可工作状态)
这个状态又可以分为正在工作和即将开始工作的状态。处于这个状态的线程,就是处在就绪队列中的线程,如果代码没有进行 sleep 操作,也没有其他可能导致阻塞的操作,则线程大多数情况处于这个状态。TIMED_WAITING
表示排队等待中……
如果代码中调用了 sleep 或者 join(带时间) 方法,则线程就会进入到这个状态,即当前线程在一定的时间内是阻塞状态,时间到了之后,阻塞状态就会解除。BLOCKED
也表示等待中,而导致这种阻塞的原因是 锁事件:synchronized(后面会介绍)线程在这个状态中表示在等待锁。WAITING
还是表示等待中,阻塞状态在被唤醒后解除(后面介绍)
以上六种状态就是Thread类中线程的可能状态,我们可以通 t.getState() 方法来获取到 t 线程的状态。
可以看到,Thread 类中对线程的状态进行了很多细分,这样做有什么意义吗?
当然是有的。因为程序员在开发的过程中经常可能遇到一种情况——程序“卡死”了!
其背后很可能是一些关键的线程阻塞了,所以在分析卡死的原因的时候,我们第一部就可以先通过getState 方法来看看当前程序中的各种关键线程所处的状态。
我们可以通过下面这个线程状态转换简图,来看看各个线程状态之间的转换关系。
线程安全
线程安全是整个多线程中最重要,也最复杂的问题。
首先要理解什么是线程安全。
这里的安全指的不是我们平常所说安全(信息安全)而是指多个线程在执行的时候,是否可能导致程序出现BUG。
因为操作系统对线程的调度是充满随机性的,这样就可能导致一些程序的执行出现BUG。
如果因为这样的随机调度而给程序引入了BUG,我们就认为这是线程不安全的,如果这样随机调度并不会引入BUG,我们认为这是线程安全的。
下面我们看一个线程不安全的典型例子:
使用两个线程,对同一个整型变量,进行自增操作,每个操作自增 5W 次。
public class Demo15 {public static int num = 0;public static void increase(){num++;}public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {increase();}});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {increase();}});t1.start();//启动线程t2.start();t1.join();//让main等待t2.join();//t1、t2都执行结束后System.out.println(num);}
}
我们运行程序,两个线程分别将 num 自增 5W 次,最终 num 应该自增了 10W 次。我们看看运行结果:
可以看到,最终 num 的结果并不是 10W 次。而且与 10W 次相去甚远。
我们可以在运行几次看看结果:
可以看到程序的结果始终和 10W 相差很多。
问题出在了哪里呢?
其实问题就处在了过个线程共享同一份数据上。
上面我们的代码中,我们创建了两个线程,同时对num这个变量进行自增。
那么自增这个操作是如何进行的呢?
因为线程调度的随机性(可以认为是“抢占式执行”),就导致多个线程在同时执行这三个指令的时候,顺序上充满了随机性。
从我们的角度,我们希望线程是这样执行的:
如果 t1 和 t2 线程的执行顺序如上图,则执行结束后,我们就可以得到想要的结果。
但是事实是 t1 和 t2 并不会自觉地等对方把自增的三个步骤执行完之后再执行,而是吭哧吭哧地对num一顿操作,并不管其他线程此时正在对它做什么。
我们可以将其中的一种可能的自增操作进行解析,就会发现虽然 t1 和 t2 同时对线程进行了自增操作,但是num实际上只增加了1。
而实际上,线程的调度还可能出现更多的顺序,例如以下这些顺序,而这些执行顺序的结果和上面的解析的都是一样只进行一次自增。
实际上,这里我并没有列全,可能的执行顺序还有,而真正符合我们预期的只有前面两种顺序。
显然,在CPU中,执行顺序是后面的概率高于前面的,于是就会出现两个线程都分别对 num 进行了自增,最终 num 却只加了 1 次的情况,所以最终的结果就不是 10W 了,并且由于这样的随机性,结果大概率会和 10W 相差很大。
那么这样的问题应该如解决呢??
一个简单的办法就是加锁!~
比如我们去厕所,进去之后得转过身把门锁上,因为厕所只能一个用,通过把门锁上,其他想要上厕所的人就看到门锁了,就只能在门口排队,等到厕所里面的人出来了,才能进去。
如果厕所没有锁,如果这是有人正在用厕所,别人再进去就会发生尴尬是事情……
所以为了避免这种尴尬是事情发生,我们进厕所之后,第一件事就是转身把门锁上。
在多线程中也是这样的,我们在 num 进行自增之间,就加个锁,等到自增操作完成,再解锁。
这样就能保证 t1 和 t2 的每次操作都是有效的。
而在Java中,加锁的方式有很多种,其中最常见的就是使用 synchronized 关键字。
public class Demo15 {public static int num = 0;//在方法前面添加上synchronized关键字synchronized public static void increase(){num++;}public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {increase();}});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {increase();}});t1.start();//启动线程t2.start();t1.join();//让main等待t2.join();//t1、t2都执行结束后System.out.println(num);}
}
上面的程序中,我们直接给方法加上 synchronized 关键字,此时线程进入方法,就会自动加锁,离开方法,就会自动解锁。
当一个线程加锁成功的时候,其他线程尝试加锁,就会触发阻塞等待,进入BLOCKED状态,直到占用锁的线程把锁释放为止。
这时我们再看程序执行结果:
num 为 10W,与预期相符~
线程不安全的原因
上面的加锁操作让多线程之间的执行由并行变成了串行状态,这不就和单线程一样了吗??
是这样的~~
所以,说过我们给所有的多线程都加锁,那么多线程的并发能力就形同虚设了。
实际上,并不是所有的线程都需要加锁,比如读操作,就好像几个人同时看一个视频,实际上并不影响,像这样的操作并不需要加锁,因为它并不会导致线程不安全。
所以为了明白哪些情况需要加锁,哪些情况不需要加锁,首先我们就要明白产生线程不安全的原因。
- 线程不安全的首要原因:抢占式执行
这是线程不安全的万恶之源!!线程之间的抢占式执行使线程间的调度充满了随机性,这是导致线程不安全的根本原因。
有没有解决办法呢?
没有。虽然这个根本原因,但是我们确实也无可奈何呀~
- 多个线程对同一个变量进行修改操作
当多个线分别对不同的变量进行修改操作的时候,线程是安全的,因为不同变量彼此之间是独立的。而多个线程对同一变量进行读操作的时候,也不会产生问题,因此这并不会改变变量本身。
解决办法:调整代码结构,使不同线程操作不同变量
- 针对变量的操作不是原子的
原子性:即操作是否是由多个步骤构成的,如果一个操作底层只有一个步骤,那么就认为这个操作是原子的。
上述例子中的操作就不是原子的。
num++ 这个操作实际上就是有三步操作组成的。因此我们也可以看到,一条 java 不一定是原子的,可能对应的是CPU中的数条指令。
如果一个操作不是原子的,那么当一个线程正在对一个变量操作时,如果中途有其他线程插进来了,这个操作被打断了,就会导致BUG。
解决办法:加锁 - 本质上加锁就是将多个操作打包成一个原子的操作。
- 内存可见性
什么是内存可见性呢?
可见性指的是一个线程对共享变量的修改,能够及时被其他线程看到。
上面的说法有点抽象,我们举一个具体的例子来说明~
现在有两个线程,它们针对同一个变量进行操作。一个反复进行读操作,另一个每隔一段时间就对变量进行一次修改。
从图中可以看到,t1 循环地1去内存中进行访问。而我们知道,读取内存的操作,相比于读取寄存器的操作是非常低效的。
这时候 t1 频繁地去内存中读数据,但是因为 t2 对变量的修改迟迟没有发生,导致 t1 每次得到的都是一样的值,这时候 t1 就有了一个大胆的想法:
我不从内存中读数据了,反正每次读的都是一样的,我们直接在寄存器中读就行了。
当 t1 做了这个决定之后,如果 t2 突然对变量进行了修改,那么此时 t1 就感知不到这个变化了。
为什么会导致这种原因呢?
实际上这是编译机代码优化产生的效果。为了保证代码的高效性,编译器是有可能在保证代码原有逻辑不变的情况下,对代码做出一些调整的。
虽然这种优化在大部分情况下都不会导致问题,但是在多线程中,却是可能翻车的!
由于多线程代码执行时的不确定性,编译器在编译阶段,很难预知执行的行为,因此进行优化的时候就可能发生误判。
下面我们看一个具体的例子。
public class Demo16 {private static int isQuit = 0;public static void main(String[] args) {Thread t = new Thread(()->{while (isQuit == 0){//用户输入之前,线程始终执行循环}System.out.println("循环结束!t线程退出");});t.start();Scanner scanner = new Scanner(System.in);System.out.println("请插入一个 isQuit 的值:");isQuit = scanner.nextInt();System.out.println("main 线程执行完毕!");}
}
在上面的例子中,当用户输入之前,线程 t 一直处于循环内,当用户输入一个不为 0 的数之后,线程 t 应该跳出循环。
当我们运行程序,输入 1 之后,发现 main 线程执行完毕了,而 t 线程却还没有输出执行完毕的语句,说明此时 t 线仍然处于循环之中,其原因就是:t 反复去内存中读取到的 isQuit 的值一样,所以他就不再去内存中读了,于是导致了所谓的内存可见性问题。
那么这个问题应该如何解决呢?
我们可以使用 volatile 关键字,保证代码每次执行到这里都要重新大内存中读取 isQuit 的值。
//在变量之前加上volatile
private static volatile int isQuit = 0;
加上关键字之后,我们再次运行程序:就可得到理想的结果了。
- 指令重排序
指令重排序也是编译器优化中的一种操作~
如何理解指令重排序?
我们可以通过一个例子进行简单的说明。
假设我们做事的顺序是这样的安排的:
快递到了,先下楼取快递 —> 回家健身十分钟 —> 下楼去做核酸。
那么按照这个顺序我们就要下楼两次,有时候我们想偷一下懒,减少一次下楼的次数:于是快递到了,我们就先不去拿,先健身十分钟,然后下楼去取快递,然后顺便去核酸点测核酸。
上面这样的操作在不影响执行效果的前提下,也提高了执行的效率。
而这在程序中也会有同样的情况,编译器会在保证代码逻辑不变的前提下,去调整执行的顺序,以提高程序的执行效率。
在单线程的程序中,这样的优化往往不会出现问题,但是在多线程中,由于代码执行的复杂度较高,编译器在代码编译阶段很难对执行效果进行准确的预测,于是就很可能进行误判,从而造成了指令重排序的问题。
那么这个问题如何解决呢?
依然是使用 volatile 关键字,以此来防止程序对于指令进行重排序。
除此之外,使用 synchronized 关键字也可以防止出现内存可见性和指令重排序的问题。
但是要说明的是虽然 synchronized 可以防止程序出现上述两个问题,但是并不代表 synchronized 可以禁止编译器做出指令重排序等的优化。
它可以防止上述问题出现的本质原因是:
- 变量加锁之前和之后需要把变量更新到内存中
- synchronized 保证了线程的串行执行,因此即使编译器对代码进行了指令重排序,也不会出现BUG
synchronized
上面的介绍中,我们多次见到了synchronized 这个关键字,它在多线程编程中的重要性可见一斑,所以接下来我们就来介绍一下 synchronized 的用法以及它的一些特性。
特性
- 互斥
首先简单了解一下 synchronized 的字面意思:
synchronized 的意思是同步,而同步这个词,在计算机中根据不同的上下文会有不同的意思。
在多线程中:同步 —> 互斥
在网络线程中:同步 —> 异步
此处的异步表示的是消息的发送发如何获取到结果,多线程中的互斥没有任何关系,与线程也没有关系~
而多线程中的 synchronized 起到互斥的效果,说明的是某个线程执行到某个对象的 synchronized 中时,其他线程如果也执行到同一个对象的 synchronized ,就会阻塞等待。
所以,进入 synchronized 修饰的代码块,相当于加锁。退出 synchronized 修饰的代码块,相当于解锁。
- 刷新内存
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
前面在讲多线程风险的时候也提到了synchronized可以解决内存可见性的问题,主要原因就在此。
- 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
什么叫自己把自己锁死的问题呢?
举个例子:你给自家的门上了一把锁,过了一段时间之后,你忘记了自己给这个门上过锁,然后当你想要进门的时候,就得一直锁打开,但是因为加锁的人就是你,所以别人也帮不了你,这就是“自己把自己锁死了”。
这样的锁也称为不可重入锁。
为了解决这个问题,synchronized就会记录上锁的人(你),如果下次要用锁的还是你,那就允许你上锁。
在可重入锁的内部,包含了“线程持有者”(可理解为上锁者/当前资源使用者)和“计数器”两个信息。
- 如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的是自己,那么仍然可以继续加锁,但是并不是真正的加锁,而是计数器+1,表示又加了一个锁。
- 释放一个锁的时候,计数器就会-1,当计数器减到0的时候,表示没有锁了,这时候资源才真正释放了。(允许别的线程来加锁)
代码示例:
public int count = 0;synchronized void increase() {count++;}synchronized void increase2() {increase();}
用法
synchronized 的使用方法主要有 3 个,下面一一进行介绍。
- 直接修饰普通的方法。
我们在使用 synchronized 的时候,本质上就是在针对某个“对象”进行加锁。
synchronized 用的锁是存在于 java 对象里面的。
在 java 中,每个类都是继承自 Object 类的,我们创建每个实例,一方面包含了我们自己写的属性,一方面包含了一些“元数据”,这个元数据就放在对象头中。
这里可以粗略地理解为,每个对象在内存中存储的时候,都有一块内存表示当前的“锁定”状态。
就像公共卫生间的每个门都有自己的锁,并且会显示有人/无人的状态。
如果当前是“无人”状态,那么就可以使用,如果是“有人”状态,则其他人要使用时,就得排队等待。
例如前面我们对自增操作进行加锁:
synchronized public static void increase(){num++;}
这里的synchronize就是在针对this来加锁,加锁操作就是在设置this的对象头的标志位。
注意:
当两个线程同时尝试对同一个对象加锁的时候,才会存在竞争, 如果两个线程针对不同的对象加锁,就不存在竞争。(好比两个男生追求的是同一个女生,就存在竞争,若追求的是不同的两个女生,则不存在竞争)
因此,加锁的对象是需要我们明确的。
- 修饰一个代码块
上面提到,加锁的对象是需要我们明确的,当我们使用synchronize来修饰一段代码的时候,需要显式指定是对哪个对象加锁。
PS: Java中任意的对象都可以作为锁对象。
public void method() {synchronized (this) { //锁当前对象}}public void method1() {synchronized (Demo17.class) { //锁类对象}}
- 修饰静态方法
public synchronized static void method2() {}
Java标准库中的线程安全类
Java 标准库中很多类都是线程不安全的,这些类可能会涉及到多线程修改共享数据的情况,但是却没有任何加锁措施。
线程不安全的类:
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
下面是一些线程安全的类。
线程安全的类
Vector(不推荐使用)
HashTable(不推荐使用)
ConcurrentHashMap
StringBuffer
String
上面这些线程安全的类中,除了最后一个String类,其他类保证线程安全的方法主要是在类内一些关键方法中加上了synchronized关键字,以保证在多线程环境下修改同一对象时不出问题。
而String类能保证线程安全的原因是:String是不可变对象,因此不存在多个线程同时修改同一个String的情况。
volatile
保证内存可见性
volatile修饰的变量,能保证“内存可见性”。
计算机在执行计算之前,需要把内存中的数据读取到CPU的寄存器中,然后再在寄存器中计算,再将计算结果写回内存。
但是CPU访问内存所需的时间比访问寄存器所需的时间高很多,因此当CPU连续多次访问内存的结果都一样的时候,CPU就会偷懒,不去内存读取数据,从而导致“内存不可见”的问题。
而volatile会强制CPU读取内存,从而保证了“内存可见性”。
JMM(Java Memory Model)是Java中的内存模型,JMM其实就是把我们所说的CPU、内存等硬件结构在Java中用专门的术语重新封装了一遍。
其中CPU就封装为工作内存(work memory)
内存就封装为(main memory)
在代码中加入volatile修饰变量之后:
写入变量时:
- 改变线程工作内存中volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存
读取变量时:- 从主内存中读取volatile变量的最新值到线程的工作内存中
- 从工作内存中读取volatile变量的副本
不保证原子性
虽然volatile可以保证内存可见性,但是这并不意味着volatile可以保证原子性,因此这一点需要大家特别注意,volatile和synchronized有着本质的区别。
volatile可以用于处理一个线程读一个线程写的情况。
wait 和 notify
由于线程之间是抢占式执行的,因此线程之间执行的先后顺序是不可预知的。
但是在实际开发中,有时候我们希望线程之间能够互相协调,这时候就可能需要指定线程之间的执行顺序。
比如篮球场上的每个运动员,都可以看成是一个独立的线程,但是一场比赛需要运动员们互相合作,比如A获得了球,就需要先传球给B,再由B来完成投篮的动作。
这里的“传球”和“投篮”分别是两个线程的动作,但是它们需要根据先传球,后投篮的顺序来执行,因此就需要用到下面的wait()和notify()方法了。
- wait() / wait(long timeout):让当前线程进入等待状态
- notify() / notifyAll():唤醒在当前对象上等待的线程
wait()、notify()、notifyAll() 都是Object类的方法
wait() 方法
调用wait()方法时:
- 当前执行代码的线程进行等待(线程进入等待队列)
- 释放当前的锁(因此wait()需要搭配synchronized来使用,否则会抛异常哦~)
- 满足一定条件时被唤醒,重新尝试获取这个锁
wait() 结束等待的条件:
- 其他线程调用该对象的notify() 方法
- wait() 等待超时(当调用wait()方法时指定了timeout(等待时间)参数时)
- 其他线程调用该等待线程的interrupted方法,导致wait() 抛出 InterruptException异常
notify() 方法
notify() 的作用即唤醒等待的线程。
- notify() 也需要在同步方法或同步代码块中使用,该方法是用来通知那些可能等待该对象的对象锁的其他线程,对其发出notify通知,使他们重新获取该对象的对象锁。
- 如果有多个线程在等待,则由线程调度器随机挑选出一个呈wait状态的线程。(注意唤醒并不遵循先来后到的原则)
- 在执行notify() 方法之后,当前线程并不会马上释放该对象锁,而是该线程将程序执行完,退出同步代码块之后,才会释放锁。
代码示例:
static class waitTast implements Runnable{private Object locker;public waitTast(Object locker) {this.locker = locker;}@Overridepublic void run() {synchronized (locker) {while (true) {try {System.out.println("wait开始");locker.wait();System.out.println("wait结束");} catch (InterruptedException e) {e.printStackTrace();}}}}}static class notifyTast implements Runnable{private Object locker;public notifyTast(Object locker) {this.locker = locker;}@Overridepublic void run() {synchronized (locker) {System.out.println("notify开始");locker.notify();System.out.println("notify结束");}}}public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(new waitTast(locker));Thread t2 = new Thread(new notifyTast(locker));t1.start();Thread.sleep(1000);t2.start();}
执行结果如下:
notifyAll() 方法
上面的notify()只能唤醒某一个等待的线程,而使用notifyAll()则可以将所有等待的线程唤醒。
代码示例:
static class waitTast implements Runnable{private Object locker;public waitTast(Object locker) {this.locker = locker;}@Overridepublic void run() {synchronized (locker) {while (true) {try {System.out.println("wait开始");locker.wait();System.out.println("wait结束");} catch (InterruptedException e) {e.printStackTrace();}}}}}static class notifyTast implements Runnable{private Object locker;public notifyTast(Object locker) {this.locker = locker;}@Overridepublic void run() {synchronized (locker) {System.out.println("notifyAll开始");locker.notifyAll();System.out.println("notifyAll结束");}}}public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(new waitTast(locker));Thread t2 = new Thread(new waitTast(locker));Thread t3 = new Thread(new waitTast(locker));Thread t4 = new Thread(new notifyTast(locker));t1.start();t2.start();t3.start();Thread.sleep(1000);t4.start();}
运行结果如下:
可以看到,只进行了依次唤醒,但是三个wait()线程都被唤醒了一次。
但是,虽然是同时唤醒3个线程,但是这3个线程之间仍然会存在锁竞争,所以并不是3个线程被唤醒后就同时执行了,而是有先有后地执行。
面试题:wait() 和 sleep() 的对比
wait() 和 sleep() 方法的相同点就是它们都能让线程暂停一段时间。
不同点:
- wait()用于线程之间通信的,而sleep()仅仅是让线程阻塞一段时间;
- wait()是Object类的方法,sleep()是Thread类的静态方法;
- wait()需要搭配synchronized使用,sleep()不需要。
多线程案例
案例一:单例模式
单例模式是一种常见的设计模式。
什么是设计模式?
设计模式就像下棋时候的棋谱,应对一些常见的/特定场景,人们会有一些固定的套路来应对。而在开发中也会存在很多常见的问题场景,针对这些问题场景的一些固定套路,就被称为设计模式。
单例模式能保证某个类在程序中只存在唯一一份实例。
即代码中的某个类,只能拥有一个实例,不能有多个。
单例模式的两种实现方式,分别是饿汉模式和懒汉模式。
饿汉模式
饿汉模式即类加载的同时,即创建实例。
static class Singleton {// static修饰的变量,称为类成员(类属性/类方法)// 一个Java程序中,一个类对象只有一份,因此static成员也只有一份private static Singleton instance = new Singleton();// 构造方法设置为private,防止程序员在其他地方new这个Singletonprivate Singleton() { }// 提供一个方法使外面能够拿到这个唯一实例public static Singleton getInstance() {return instance;}}
懒汉模式
懒汉模式即在需要使用的时候才创建实例。
对比饿汉模式和懒汉模式:暑假作业
饿汉模式:一放假就把暑假作业写完
懒汉模式:等到要开学了,老师要检查暑假作业的时候才写,不检查就不写了
static class Singleton2 {private static Singleton2 instance = null;private Singleton2() { }// 只有当需要该实例的时候,才创建实例public static Singleton2 getInstance() {if (instance == null) {instance = new Singleton2();}return instance;}}
上面代码中可以看到,懒汉模式中,既包含了读,又包含了修改,并且读和修改是分开的两个步骤,因此饿汉模式中创建实例的操作并不是原子性的,存在线程安全的问题。
于是我们需要实现一个线程安全的单例模式~
即在可能发生线程安全问题的地方加上synchronized关键字。
但是线程安全问题只发生在首次创建实例的时候,一旦实例创建好之后,后面多线程调用getinstance() 的时候都不会导致线程安全问题,但是因为加上synchronized关键字,即使不存在线程安全问题,但是仍然存在大量的锁竞争。
因此我们可以使用双重if判定+volatile来进行解决:
static class Singleton3 {// 加上volatile保证内存可见性private static volatile Singleton3 instance = null;private Singleton3() { }// 只有当需要该实例的时候,才创建实例public synchronized static Singleton3 getInstance() {// 外层if保证实例已经创建出来之后,直接返回实例,不会存在锁竞争if (instance == null) {// 实例还没创建出来的时候,加锁保证线程安全synchronized (Singleton3.class) {if (instance == null) {instance = new Singleton3();}}}return instance;}}
案例二:阻塞式队列
概念
阻塞队列是一种特殊的队列,遵循“先进先出”的原则。
阻塞队列是一种线程安全的数据结构:
若队列为空时,尝试出队就会阻塞,直到队列不为空;
若队列为满时,尝试入队就会阻塞,知道队列不为满。
阻塞队列的一个典型应用场景就是“生产者消费者模型”,这是一种非常典型的开发模型。
生产者消费者模型
如何理解生产者消费者模型?
其实就是在生产者和消费者之间多了一个中介(缓冲区)。
比如我们要买猪肉,我们是消费者,但是我们不直接和养猪的伯伯进行交易,而是去超市购买,超市就作为了生产者和消费者之间的一个容器,消费者并不关心超市里卖的猪肉是从哪个伯伯家来的,只关心猪肉本身,而伯伯也不关心猪肉最后是卖给谁了。
这样的模型最大的特点就是生产者和消费者之间充分地解耦合了。
假如今天A伯伯不卖肉了,超市可以从B伯伯那里进货,但是对于消费者来说,更换了伯伯的事情并不会产生影响。
而阻塞队列就相当于这样一个中介,使生产者和消费者解耦,并且能作为一个缓冲区,平衡生产者和消费者的处理能力。
如何理解平衡两者的处理能力?
还是拿买肉来举例子:假如今天的猪肉太多卖不完了,超市就会向伯伯少进点货。假如今天猪肉卖完了,超市就可以跟消费者说等一等,等有肉送来了或者明天再来买。
这样就可以做到削峰填谷的作用:防止需求量暴涨,导致服务器工作量暴涨进而导致崩溃。
Java标准库中的阻塞队列
Java标准库中内置了阻塞队列,可供我们直接使用:
public class Demo21 {public static void main(String[] args) throws InterruptedException {BlockingDeque<String> queue = new LinkedBlockingDeque<>();// 入队queue.put("abc");// 出队,若没有put直接take,则会阻塞String elem = queue.take();}
}
生产者消费者模型:
public static void main(String[] args) {BlockingQueue<Integer> blockingQueue = new LinkedBlockingDeque<>();Thread customer = new Thread(()-> {while (true) {try {int value = blockingQueue.take();System.out.println("消费元素:" + value);} catch (InterruptedException e) {e.printStackTrace();}}},"消费者");customer.start();Thread producer = new Thread(()->{Random random = new Random();while (true) {try {int num = random.nextInt(1000);System.out.println("生产元素:" + num);blockingQueue.put(num);Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}},"生产者");producer.start();try {customer.join();producer.join();} catch (InterruptedException e) {e.printStackTrace();}}
运行结果如下:
阻塞队列实现
下面我们自己来实现一个阻塞队列(思路如下):
- 基于数组(链表也可以)实现一个循环队列
- 使用synchronized进行加锁控制
- put 插入元素时,若队满,则阻塞(循环wait),take取出元素时,若队空,则阻塞(循环wait)
public class BlockingQueue {private int[] items = new int[1000];private volatile int size = 0;private int head = 0;private int end = 0;public void put(int value) throws InterruptedException {synchronized (this) {// 使用while防止notifyAll()时该线程被唤醒,但是当轮到该线程时,队列又满了的情况while (size == items.length) {wait();}items[end] = value;end = end + 1;if (end >= items.length) {end = 0;}size++;notifyAll();}}public int take() throws InterruptedException {int ret = 0;synchronized (this) {while (size == 0) {wait();}ret = items[head];head = head + 1;if (head >= items.length) {head = 0;}size--;notifyAll();}return ret;}//测试public static void main(String[] args) {BlockingQueue blockingQueue = new BlockingQueue();Thread customer = new Thread(()-> {while (true) {try {int value = blockingQueue.take();System.out.println("消费元素:" + value);} catch (InterruptedException e) {e.printStackTrace();}}},"消费者");customer.start();Thread producer = new Thread(()->{Random random = new Random();while (true) {try {int num = random.nextInt(1000);System.out.println("生产元素:" + num);blockingQueue.put(num);Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}},"生产者");producer.start();try {customer.join();producer.join();} catch (InterruptedException e) {e.printStackTrace();}}
}
运行结果:
案例三:定时器
概念
定时器是软件开发中的一个重要组件,类似一个“闹钟”,达到一个设定的时间之后,就执行某个指定任务(一段代码)。
比如网络通信中,如果对方500ms内返回数据,则断开连接尝试重连。
Java标准库中的定时器
标准库中提供了一个Timer类,Timer类的核心方法为schedule。
public class Demo22 {public static void main(String[] args) {Timer timer = new Timer();// 两个参数,第一个参数指定即将要执行的任务代码,第二个参数指定多长时间(毫秒)之后执行timer.schedule(new TimerTask() {@Overridepublic void run() {System.out.println("hello, Stella");}},3000);}
}
定时器的实现
定时器的构成:
- 一个带优先级的队列(高效地把执行时间最小的任务找出来)
- 队列中的每个元素都是一个Task对象
- Task带有一个时间属性
- 有一个worker线程一直扫描队首元素,看队首元素是否需要执行
代码如下:
import java.util.concurrent.PriorityBlockingQueue;public class MyTimer {// 通过队列在组织Task对象private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();// 通过schedule往队列中添加Task对象public void schedule(Runnable command, long after) {Task task = new Task(command,after);queue.offer(task);}public MyTimer() {Worker worker = new Worker();worker.start();}//worker线程,用于不停扫描队首元素,看看是否能执行这个任务class Worker extends Thread{@Overridepublic void run() {while (true) {try {Task task = queue.take();long curTime = System.currentTimeMillis();if (task.time > curTime) {// 时间未到,把任务放回队列中queue.put(task);} else {// 时间到了,执行任务task.run();}} catch (InterruptedException e) {e.printStackTrace();break;}}}}// Task类用于描述一个任务,包含一个Runnable对象和一个time(毫秒时间戳)static class Task implements Comparable<Task> {private Runnable command;private long time;public Task(Runnable command, long time) {this.command = command;this.time = System.currentTimeMillis() + time;}public void run() {command.run();}@Overridepublic int compareTo(Task o) {//时间小的排在前面return (int)(time - o.time);}}
}
我们观察上面代码中的工作线程,while(true)中线程一直在扫描,会导致每秒钟访问队首元素几万次,这样的忙等会造成无意义的浪费。
为了解决以上问题,我们可以引入一个mailBox对象,借助该对象的wait/notify来解决上述忙等问题。
完整代码如下:
import java.util.concurrent.PriorityBlockingQueue;public class MyTimer {private Object mailBox = new Object();// 通过队列在组织Task对象private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();// 通过schedule往队列中添加Task对象public void schedule(Runnable command, long after) {Task task = new Task(command,after);queue.offer(task);// 当添加新任务的时候,都唤醒一下worker线程。(因为新插入的任务可能是需要马上执行的)synchronized (mailBox) {mailBox.notify();}}public MyTimer() {Worker worker = new Worker();worker.start();}//worker线程,用于不停扫描队首元素,看看是否能执行这个任务class Worker extends Thread{@Overridepublic void run() {while (true) {try {Task task = queue.take();long curTime = System.currentTimeMillis();if (task.time > curTime) {// 时间未到,把任务放回队列中queue.put(task);// 不再忙等,而使用waitsynchronized (mailBox) {// 指定等待时间 waitmailBox.wait(task.time - curTime);}} else {// 时间到了,执行任务task.run();}} catch (InterruptedException e) {e.printStackTrace();break;}}}}// Task类用于描述一个任务,包含一个Runnable对象和一个time(毫秒时间戳)static class Task implements Comparable<Task> {private Runnable command;private long time;public Task(Runnable command, long time) {this.command = command;this.time = System.currentTimeMillis() + time;}public void run() {command.run();}@Overridepublic int compareTo(Task o) {//时间小的排在前面return (int)(time - o.time);}}
}
案例四:线程池
概念
我们知道,进程相对于线程更重,如果我们频繁地创建和销毁,就会导致开销很大,这时候,我们就可以通过线程或者进程池的方式来解决问题。
而线程虽然比进程更轻量,但是如果创建和销毁的频率进一步增加,开销也会比较大,这时候就可以使用线程池。
线程池就是把线程提前创建好,放到池子里,后续需要使用线程的时候,就直接从池子里取,而不必向系统申请了,等到线程用完的时候,就把线程放回线程池中,这样就可以降低创建和销毁线程的频率,从而提高效率。
为什么使用线程池会比直接创建和销毁更快呢?
因为我们写的代码属于“用户态”的代码,当我们创建和销毁线程时,需要进入到“内核态”,而内核中的操作不是我们可控制的,并不一定我们把请求交给内核,内核就立即执行我们的请求(因为内核本身还有很多其他事情要处理),因此纯用户态的操作效率往往会比经过内核处理的操作效率更高。
Java标准库中的线程池
标准库中的线程池叫做ThreadPoolExecutor。
下面是源码中的构造方法:
我们依次来看一下这里的参数:
- int corePoolSize 核心线程数
- int maximunPoolSize 最大线程数
- long keepAliveTime 多余的线程在销毁前等待新任务的时间
- TimeUnit unit 时间的单位(s/ms/us)
- BlockingQueue<Runnable> workQueue 任务队列,用于组织任务
- ThreadFactory threadFactory 线程工厂(描述线程是如何创建出来的)
- RejectedExecutionHandler handler 拒绝策略,即当任务队列满的时候的应对措施(如直接忽略/阻塞等待/丢弃最老的任务等)
理解前三个参数:
我们可以把线程池想象成一个公司,公司中的员工分为两类:正式员工和临时工。正常情况下,公司的任务由正式员工来完成,当公司业务量猛增的时候,正式员工忙不过来,因此需要招一些临时员工来分担任务,当任务完成后,公司正式员工就可以完成所有事情,因此不再需要临时员工,就可以把临时员工辞退。
而上面的核心线程数就可以对应正式员工的数量,最大线程数对应公司所有员工的人数(正式员工+临时员工),线程存活时间则指的是临时员工在干完活之后,在公司中摸鱼的时间(当前没有任务,但是也没有被解雇,如果等到某个时间还没有新任务,就解雇)。
虽然线程池中的参数可以有这么多,但是使用的时候最重要的参数仍然是——线程池中线程的个数。
那么在开发的时候,如果涉及到并发编程,使用线程池的话,线程数应该设置为多少才合适呢?
线程数的设置并不能简单地根据机器的内核数量来设置,而是应该经过性能测试,来找到合适的值,不同场景下,线程数的设置不能一概而论,而是应该根据需求,找到一个让程序速度可以接受,CPU占用率也合理的平衡点。
(注意:CPU 占用率并不是越高越好,对于服务器端来说,CPU应留有一定的冗余,以应对一些请求暴涨等突发状况。)
ThreadPoolExecutor 直接使用会比较麻烦,因此标准库中又提供了一个简化版的线程池Executors,本质是对ThreadPoolExecutor 进行了封装,提供了一些默认参数。
使用方法如下:
public class Demo23 {public static void main(String[] args) {//使用Executors.newFixedThreadPool(10)创建出固定包含10个线程的线程池ExecutorService pool = Executors.newFixedThreadPool(10);// 通过ExecutorService.submit注册一个任务到线程池中pool.submit(new Runnable() {@Overridepublic void run() {System.out.println("hello");}});}
}
Executors创建线程池的几种方式:
newFixedThreadPool:创建固定线程数的线程池
newCachedThreadPool:创建线程数目动态增长的线程池
newSingleThreadExecutor:创建只包含单个线程的线程池
newScheduledThreadPool:设定延迟时间后执行命令,或者定期执行命令,是进阶版的Timer
以上就是本篇文章的全部内容啦~
主要介绍了进程和线程的区别,线程的创建以及使用、线程不安全的原因和解决方法,最后介绍了4个典型的多线程案例,对于多线程的内容,需要写代码,通过运行程序来加深对于多线程的理解。
如果你觉得文章有用,记得点个一键三连噢!~
也欢迎你来评论区和我交流!
【手把手带你学Java EE】多线程那些事,你了解了吗?相关推荐
- java基本数据类型_老杜带你学Java【第六课】
上期链接:老杜带你学Java[第五课] 01 写在前面 欢迎来到杜老师的「零基础学Java」课堂~今后,我们就是Java软件工程师了.(此处应该有掌声
- java 获取ip地址_老杜带你学Java【第二课】
上期链接:老杜带你学Java[第一课] 01 写在前面 欢迎来到杜老师的「零基础学Java」课堂~今后,我们就是Java软件工程师了.(此处应该有掌声???)本专题为<零基础学Java>专 ...
- 我要带徒弟学JAVA架构 ( 写架构,非用架构 )
我要带徒弟学JAVA架构 (写架构,非用架构) 很多人做java开发2,3年后,都会感觉自己遇到瓶颈.什么都会又什么都不会,如何改变困境,为什么很多人写了7,8年还是一个码农,工作中太多被动是 ...
- 【零基础学Java】—多线程(四十九)
[零基础学Java]-多线程(四十九) 一.并发与并行 并发:指两个或多个事件在同一时间段内发生 并行:指两个或多个事件在同一时刻发生(同时发生) 二.进程和线程
- 咸鱼带你学Java—关键字与标识符
目录 一.关键字的使用 1.定义 2.特点 3.具体的关键字 二.保留字 1.定义 2.具体保留字 3.注意 三.标识符的使用 1.定义 2.涉及到的结构 3.规则 4.规范 5.注意点 一.关键字的 ...
- 越学越有趣:『手把手带你学NLP』系列项目01 ——词向量应用的那些事儿
点击左上方蓝字关注我们 课程简介 "手把手带你学NLP"是基于飞桨PaddleNLP的系列实战项目.本系列由百度多位资深工程师精心打造,提供了从词向量.预训练语言模型,到信息抽取. ...
- 培训讲师的自我修养——《手把手教你学Java》
当前,人才是困扰IT企业发展的第一大问题,企业间竞争的本质就是人才的竞争.然而人才却有"结构性过剩"的问题,就是因为有高深的理论.尖端的技术.耀眼的学位的"人材" ...
- 越学越有趣:『手把手带你学NLP』系列项目02 ——语义相似度计算的那些事儿...
点击左上方蓝字关注我们 课程简介 "手把手带你学NLP"是基于飞桨PaddleNLP的系列实战项目.本系列由百度多位资深工程师精心打造,提供了从词向量.预训练语言模型,到信息抽取. ...
- java 8进制串转中文_老杜带你学Java【第六课】
上期链接:老杜带你学Java[第五课] 01 写在前面 欢迎来到杜老师的「零基础学Java」课堂~今后,我们就是Java软件工程师了.(此处应该有掌声???)本专题为<零基础学Java>专 ...
最新文章
- 利用who,w,ps和top等指令查看linux下的进程执行情况.,UNIXLINUX操作系统实验指导书...
- IP选路与动态选路协议(六)
- 创建分辨率自适应的Windows Phone 8应用程序
- 牛客网_PAT乙级_1019. 数字黑洞 (20)
- C++学习之路 | PTA乙级—— 1023 组个最小数 (20分)(精简)
- [转】:Android调试工具及方法
- 突发!Nginx 之父被拘留,原因竟然是“接私活儿”?
- PHP对自己I/O流访问的封装(转)
- 编译器优化对齐(字节对齐)
- Android开发资料超级给力小游戏(精典美女搓搓 妄撮版)源码
- 清华大学计算机秦凌霄,海南25名考生获得北大清华自主招生入选资格
- qq系统软件测试计划,软件测试设计报告案例——qq空间.doc
- 本野桂 出任索尼中国专业系统集团总裁
- 银联在线php支付接口,ecshop银联在线支付接口插件(官方版)
- 超好用的pdf编辑+pdf转word工具 – Adobe Acrobat Pro DC下载
- 使用Marked库出现bug:Uncaught TypeError: marked is not a function at <anonymous>:1:14
- 使用gpg工具实现公钥加密
- 基于SSM快递取件管理系统
- 800个电子竞技设计大赛毕业设计产品开发资料
- 活动回顾(PPT+视频)|Python开发者的年度聚会,我们的现场直击!