清华扫地僧整理的全网最全多线程详解,看完怀疑自己的认知
前言
现在越来越多的公司,对精通多线程的的人才越来越重视,可见多线程技术有多热门。今天,小编结合清华扫地僧级别大佬的分享,为大家带来这篇多线程的总结,希望大家能够喜欢。
一、线程概述
几乎所有的操作系统都支持同时运行多个任务,一个任务通常就是一个程序,每个运行中的程序就是一个进程。当一个程序运行时,内部可能包含了多个顺序执行流,每个顺序执行流就是一个线程。
二、线程与进程
进程概述:
几乎所有的操作系统都支持进程的概念,所有运行中的任务通常对应一个进程( Process)。当一个程序进入内存运行时,即变成一个进程。进程是处于运行过程中的程序,并且具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位。
进程特征:
1、独立性:进程是系统中独立存在的实体,它可以拥有自己独立的资源,每一个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间
2、动态性:进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,这些概念在程序中都是不具备的
3、并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响。
线程:
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
并发和并行:
并发:同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行
并行:同一时刻,有多条指令在多个处理器上同时执行
多线程:
概述:
多线程就是几乎同时执行多个线程(一个处理器在某一个时间点上永远都只能是一个线程!即使这个处理器是多核的,除非有多个处理器才能实现多个线程同时运行。)。几乎同时是因为实际上多线程程序中的多个线程实际上是一个线程执行一会然后其他的线程再执行,并不是很多书籍所谓的同时执行。
多线程优点:
进程之间不能共享内存,但线程之间共享内存非常容易。
系统创建进程时需要为该进程重新分配系统资源,但创建线程则代价小得多,因此使用多线程来实现多任务并发比多进程的效率高
Java语言内置了多线程功能支持,而不是单纯地作为底层操作系统的调度方式,从而简化了Java的多线程编程
三、使用多线程
多线程的创建:
(1)、继承Thread类:
第一步:定义Thread类的之类,并重写run方法,该run方法的方法体就代表了线程需要执行的任务
第二步:创建Thread类的实例
第三步:调用线程的start()方法来启动线程
(2)、实现Runnable接口:
第一步:定义Runnable接口的实现类,并重写该接口的run方法,该run方法同样是线程需要执行的任务
第二步:创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象
(3)、使用Callable和Future创建线程
细心的读者会发现,上面创建线程的两种方法。继承Thread和实现Runnable接口中的run都是没有返回值的。于是从Java5开始,Java提供了Callable接口,该接口是Runnable接口的增强版。Callable接口提供了一个call()方法可以作为线程执行体,但call()方法比run()方法功能更强大。
创建并启动有返回值的线程的步骤如下:
第一步:创建 Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值,再创建 Callable实现类的实例。从Java8开始,可以直接使用 Lambda表达式创建 Callable对象
第二步:使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call方法的返回值
第三步:使用FutureTask对象作为Thread对象的target创建并启动新线程
第四步:通过FutureTask的get()方法获得子线程执行结束后的返回值
创建线程的三种方式的对比:
采用Runnable、Callable接口的方式创建多线程的优缺点:
优点:
1、线程类只是实现了 Runnable接口或 Callable接口,还可以继承其他类
2、在这种方式下,多个线程可以共享同一个 target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
缺点:
编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。
采用继承 Thread类的方式创建多线程的优缺点:
优点:
编写简单,如果需要访问当前线程,则无须使用 Thread.current Thread()方法,直接使用this即可获得当前线程
缺点:
因为线程已经继承了Thread类,所以不能再继承其他类
线程的生命周期:
新建和就绪状态:
当程序使用new关键字创建一个线程后,该线程就处于新建状态。
当线程对象调用了start()方法后,该线程就处于就绪状态。
运行和阻塞状态:
如果处于就绪状态的线程获取了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态。
当线程调用sleep(),调用一个阻塞式IO方法,线程会被阻塞
死亡状态:
1、run()或者call()方法执行完成,线程正常结束
2、线程抛出一个未捕获的Exception或Error
3、直接调用该线程的stop方法来结束该线程——该方法容易导致死锁,不推荐使用
线程状态转化图四、控制线程
join线程
Thread提供了让一个线程等待另一个线程完成的方法——join方法。当在某个程序执行流中调用其直到被 join方法加入的join线程执行完为止
运行结果
后台线程:
有一种线程,它是在后台运行的,它的任务是为其他的线程提供服务,这种线程被称为“后台线程( Daemon Thread)”,又称为“守护线程”或“精灵线程”。JVM的垃圾回收线程就是典型的后台线程。
后台线程有个特征:如果所有的前台线程都死亡,后台线程会自动死亡。
调用 Thread对象的 setDaemon(true)方法可将指定线程设置成后台线程。下面程序将执行线程设置成后台线程,可以看到当所有的前台线程死亡时,后台线程随之死亡。当整个虚拟机中只剩下后台线程时,程序就没有继续运行的必要了,所以虚拟机也就退出了。
运行结果:
线程睡眠:
如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用 Thread类的静态 sleep方法来实现。 sleep方法有两种重载形式
static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入阻塞状态
static void sleep(long millis,int nanos):让当前正在执行的线程暂停millis毫秒加上nanos毫微秒,并进入阻塞状态,通常我们不会精确到毫微秒,所以该方法不常用
改变线程优先级:
每个线程执行时都有一定的优先级,优先级高的线程获得较多的执行机会,优先级低的线程则获得较少的执行机会。
每个线程默认的优先级都与创建它的父线程的优先级相同,在默认情况下,main线程具有普通优先级,由main线程创建的子线程也具有普通优先级。
Thread类提供了 setPriority(int newPriority)、 getPriority()方法来设置和返回指定线程的优先级,其中 setPriority()方法的参数可以是一个整数,范围是1-10之间,也可以使用 Thread类的如下三个静态常量
MAX_PRIORITY:其值是10
MIN_PRIORITY:其值时1
NORM_PRIPRITY:其值是5
五、线程同步
线程安全问题:
现有如下代码:
现在我们来分析一下以上代码:
我们现在希望实现的操作是模拟多个用户同时从银行账户里面取钱,如果用户取钱数小于等于当前账户余额,则提示取款成功,并将余额减去取款钱数,如果余额不足,则提示余额不足,取款失败。
Account 类:银行账户类,里面有一些账户的基本信息,以及操作账户信息的方法
DrawThread类:继承了Thread,是一个多线程类,用于模拟多个用户操作同一个账户的信息
DrawTest:测试类
这时我们运行程序可能会看到如下运行结果:
如何解决线程安全问题:
①、同步代码块:
为了解决线程问题,Java的多线程支持引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块。同步代码块的语法格式如下:
synchronized(obj){
//此处的代码就是同步代码块
}
我们将上面银行中DrawThread类作如下修改:
我们来看这次的运行结果:
我们发现结果变了,是我们希望看到的结果。因为我们在可能发生线程安全问题的地方加上了synchronized代码块
②:同步方法:
与同步代码块对应,Java的多线程安全支持还提供了同步方法,同步方法就是使用 synchronized关键字来修饰某个方法,则该方法称为同步方法。对于 synchronized修饰的实例方法(非 static方法)而言,无须显式指定同步监视器,同步方法的同步监视器是this,也就是调用该方法的对象。同步方法语法格式如下:
public synchronized void 方法名(){
//具体代码
}
③、同步锁:
从Java5开始,Java提供了一种功能更强大的线程同步机制—一通过显式定义同步锁对象来实现同步,在这种机制下,同步锁由Lock对象充当。
Lock提供了比 synchronized方法和 synchronized代码块更广泛的锁定操作,Lock允许实现更灵活的结构,可以具有差别很大的属性,并且支持多个相关的 Condition对象。
在实现线程安全的控制中,比较常用的是 ReentrantLock(可重入锁)。使用该Lock对象可以显式加锁、释放锁,通常使用ReentrantLock的代码格式如下:
死锁:
当两个线程相互等待对方释放同步监视器时就会发生死锁,Java虚拟机没有监测,也没有采取措施来处理死锁情况,所以多线程编程时应该采取措施避免死锁岀现。一旦岀现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
死锁是很容易发生的,尤其在系统中出现多个同步监视器的情况下,如下程序将会出现死锁
运行结果:
从图中可以看出,程序既无法向下执行,也不会抛出任何异常,就一直“僵持”着。究其原因,是因为:上面程序中A对象和B对象的方法都是同步方法,也就是A对象和B对象都是同步锁。程序中两个线程执行,副线程的线程执行体是 DeadLock类的run()方法,主线程的线程执行体是 Deadlock的main()方法(主线程调用了init()方法)。其中run()方法中让B对象调用b进入foo()方法之前,该线程对A对象加锁—当程序执行到①号代码时,主线程暂停200ms:CPU切换到执行另一个线程,让B对象执行bar()方法,所以看到副线程开始执行B实例的bar()方法,进入bar()方法之前,该线程对B对象加锁——当程序执行到②号代码时,副线程也暂停200ms:接下来主线程会先醒过来,继续向下执行,直到③号代码处希望调用B对象的last()方法——执行该方法之前必须先对B对象加锁,但此时副线程正保持着B对象的锁,所以主线程阻塞;接下来副线程应该也醒过来了,继续向下执行,直到④号代码处希望调用A对象的 last()方法——执行该方法之前必须先对A对象加锁,但此时主线程没有释放对A对象的锁——至此,就出现了主线程保持着A对象的锁,等待对B对象加锁,而副线程保持着B对象的锁,等待对A对象加锁,两个线程互相等待对方先释放,所以就出现了死锁。
六、线程池
系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互。在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。
与数据库连接池类似的是,线程池在系统启动时即创建大量空闲的线程,程序将一个 Runnable对象或 Callable对象传给线程池,线程池就会启动一个空闲的线程来执行它们的run()或call()方法,当run()或call()方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个Runnable对象的run()或call()方法。
创建线程池的几个常用的方法:
1.newSingleThreadExecutor创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
2.newFixedThreadPool创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
3.newCachedThreadPool创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,
那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
4.newScheduledThreadPool创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
运行结果:
后记
怎么样?看完这些清华大佬整理的多线程笔记,是不是扩充了自己的认知?如果感觉到有帮助,请多多点赞评论转发,关注小编,你们的支持就是小编最大的动力~~~