美文网首页
JAVA并发编程核心知识

JAVA并发编程核心知识

作者: 烦远远 | 来源:发表于2021-05-14 19:04 被阅读0次

    线程和进程

            每个正在系统上运行的程序都是一个进程。每个进程包含一到多个线程。线程是一组指令的集合,或者是程序的特殊段,它可以在程序里独立执行。也可以把它理解为代码运行的上下文。所以线程基本上是轻量级的进程,它负责在单个程序里执行多任务。通常由操作系统负责多个线程的调度和执行。

            使用线程可以把占据时间长的程序中的任务放到后台去处理,程序的运行速度可能加快,在一些等待的任务实现上如用户输入、文件读写和网络收发数据等,线程就比较有用了。在这种情况下可以释放一些珍贵的资源如内存占用等等。

            如果有大量的线程,会影响性能,因为操作系统需要在它们之间切换,更多的线程需要更多的内存空间,线程的中止需要考虑其对程序运行的影响。通常块模型数据是在多个线程间共享的,需要防止线程死锁情况的发生。

    总结:进程是所有线程的集合,每一个线程是进程中的一条执行路径。

    多线程的作用

        多线程提高程序效率(一件事可以交给几个人来干比一个人干得快)

        举例: 1.迅雷多线程下载。2.分批发送短信。3.后台任务:定期大量信息采集(从excel同步物料、供应商...)。4.IO操作(java传统的io是阻塞的)。

    项目中实际应用到的:使用poi导出excel的时候:1. 高并发数据结构封装复杂:使用线程池创建多线程导出可以防止高并发情况下的内存溢出。2. limt 分页从数据库导出不同sheet的excel。

    创建多线程的方法

        创建多线程的方式众说纷纭,按照oracle官方的解释就说了两种方式:1.继承Thread类 2.实现Runnable接口。下面分别示范两种创建方式:

    1.继承Thread类

    继承Thread类

    2.实现Runnable接口

    实现Runnable接口方式

    3.两种方式对比哪种方式更好

            显而易见是第二种:实现Runnable接口更好,因为 继承Thread类只支持单继承,不利于程序的扩展,其次继承的话对资源浪费比较严重,每启用一个线程都要去 创建--执行--销毁--回收,而Runnable接口的话就可以去利用线程池之类的工具类

    启动线程

            首先说结论:正确启动线程的方法是调用start()方法。

    1.run()方法和start()方法比较

    run和start比较

            如上图创建了一个线程,我们分别调用它的run()方法和start()方法通过打印线程名称发现,我们执行run()是直接通过主线程去调用run(),跟普通方法调用一样。而调用start()才是真正的创建了一个Thread-0的线程,并且执行了run()。

    2.多线程的启动顺序

            如图:我创建了两个线程,分别给他们赋值线程名字,而当我们去运行后发现,线程的调用顺序却跟代码书写顺序不一样,线程1和线程2在代码中是有先后顺序的,但是执行后发现结果却不一样,2执行后执行1。当然我们多运行几次后发现他们的顺序似乎不固定,是随机的。

    两个线程启动

    下面我们再来看看如下一段代码:

    主线程放在创建线程之后

            根据代码书写的顺序和运行的结果我们似乎发现了一个规律:就是main线程不管放在哪里都是最先启动的,而创建的线程之间似乎启动并没有什么顺序,随即启动的。这个结论是正确的吗?我们接着看这段代码:

    对main线程我们增加点执行时间

            通过上段代码我们发现:新创建的线程斌没有等主线程结束以后再执行,那我们似乎明白了,主线程跟我们子线程之前的执行并没有什么关系。我们启动主线程创建完子线程之后,主线程和子线程之间分别各自去执行,并没有相互影响。只是因为一般情况下主线程先启动占用了cpu资源,所以主线程优于子线程的概率比较大而已。

    3.启动两次start会怎样呢

    两次启动start()

            上图代码中我们调用了两次start(),但是却抛出了一个异常,好像是线程状态不正确造成的异常,那么下面我们来剖析一下Thread类的start()源码。

    start方法 threadStatus属性

            通过阅读Thread的源码我们发现,在初始化Thread的时候我们将它的threadStatus属性给赋予了0,通过属性注解 ’not yet started‘ 我们可以看到它代表的意思是线程还没有被启动,所以我们才能执行。我们通过打断点,发现第一次start的时候,断点进来的时候threadStatus是0,执行完毕,第二个start断点进来后属性threadStatus变成了5,直接抛出异常。

    线程休眠与唤醒

    1.sleep方法

            sleep(Long mille)是Thread类的方法,用来休眠线程,一段时间后继续唤醒线程,注意:sleep()不会释放锁,在它休眠的时候别的线程进不来。

            下面我们用一段代码来演示线程休眠,如下图创建一个线程,我们要打印10个数字信息,在for循环中我们调用sleep方法,休眠5秒后再进行打印操作,程序运行起来后间隔五秒钟开始打印信息。

    sleep()方法演示

            下面是sleep()不会释放锁的演示:我们启动了两个线程,加上了synchronized同步代码块,假如第一个线程获得锁以后,他会等整个程序执行完以后才会释放锁让第二个线程进来。我们在程序中sleep5秒看运行结果,第一个线程进来,五秒后第一个线程结束第二个线程拿到锁才进来。所以sleep方法不会释放锁。

    加上synchronized同步代码块

    2.wait方法

            wait(Long mills)方法是Object类中的方法,执行wait方法也是线程等待一段时间,并且释放锁,但是当wait等待完成后,锁会被两个线程所争抢,不一定是由谁争抢到锁。

    wait方法演示

    3.notify和notifyAll

            notify和notifyAll都是唤醒线程的方式,配合wait方法使用。都是object的方法,线程调用wait方法后释放锁,并且线程处于等待状态,等另外一个线程调用notify后,被wait的线程会被唤醒去重新争抢锁。

            下图代码演示:我们启用了两个线程,都用了同把锁object,线程1先启动,然后去wait.线程3去调用notify唤醒线程一。两个线程之间间隔一段时间,保证线程1先拿到锁。

            看打印结果,线程1先拿到锁,然后wait释放了锁,然后线程3拿到了锁,调用notify唤醒了线程1,然后线程1又抢到了锁。

    notify和wait演示

            notifyAll是唤醒所有wait的线程,然后被唤醒的线程去争抢锁。

    停止线程

    1.正确停止线程

            原理:使用interrupt来标记一个通知,去中断线程。我们希望线程的停止时是可以将一些有风险,未完成的任务结束后再停止,然后再去停止线程,线程的停止权应该由线程的设计者来决定,而不是由线程的调用者来强行中断。

    1.1 第一种:普通线程停止情况

            我们都知道,停止线程的时候我们可以通过使用thread.interrupt()进行线程停止,但是如下代码,我想让它创建完线程后10毫秒后停止,但是实际上线程走完了它才自动结束的,我们加的interrupt()并没有起作用。

    只添加interrupt()

            再看下面的这段代码,我们发现在while的条件里我们增加了判断本线程.isInterrupted()的判断条件,看到打印的日志,大概10毫秒线程就中断了,当我们施加终止信号,会在这儿接收到被用来中断线程,中断线程的设计被线程本身的设计所掌握。

    1.2 第二种:阻塞情况下停止线程

            线程阻塞:就是线程运行中出现了等待。sleep()

            如下图我们编写了一个线程类,,在线程start后他会执行sleep(1000) 休眠1s中的状态,当10毫秒后我们对它加了一个中断信号,这时候线程正处于阻塞状态,他应对终止信号的时候会停止当前操作,并且抛出一个 java.lang.InterruptedException: sleep interrupted 异常。

    run()中有sleep()阻塞

    1.3 第三种 线程每次迭代后都阻塞

                   每次循环都阻塞,如图我们看一下如下一段代码,我们在线程启动5s后对他进行中断操作,出现跟上一个一样的状态。

    每一次循环都阻塞

            实际上我们把 !Thread.currentThread().isInterrupted()这段循环处的条件给去掉以后,还是跟上述结果是一样的,那我们明白了,线程在阻塞状态下接收到中断标记时候它会自动停止线程,并且抛出异常。不用每次都检查是否中断。

    去掉是否中断的判断条件

    1.4 while内 try——catch 的情况

            如下图代码:我们将上面1.3情况代码做了下面改动,将try-catch放在了while里面,然后将判断是否中段的条件也加上了,看看下面的运行结果,程序开始运行5s后抛出了sleep interrupted的异常,程序又开始运行,知道运行完线程才结束。这是什么原因呢?我们不加 Thread.currentThread().isInterrupted()判段,程序走完没问题,异常在while里被捕获了,程序还可以接着走,继续循环,但是加上中断信号的判断条件为什么线程还不中断呢?

            因为:我们调用interrupt()方法,程序在sleep()中,导致抛出异常了,由于sleep的设计,使得标记的中断信号被清除了,但是while内异常被捕获了,程序还可以接着下一次循环运行,所以Thread.currentThread().isInterrupted()检测中断信号会失效,没有条件能满足跳出循环,程序会一直运行完。

    while内try-catch

    1.5 最佳处理线程停止的两种方式:

            前提:在run方法中是无法再往上throws异常的,请看Thread类的源码,我们自己的线程类实际上是重写了run方法,但是在Thread类中 run 并没有抛出异常,所以它的子类就不能抛出异常(父亲不坏,儿子不能比父亲坏)。java异常体系不了解可以去看我的上篇文章 java中如何正确处理异常

    1.5.1 run方法中处理中断

            如下图:我们对1.4线程类的run方法做了改动,当我们捕获到异常的时候,重新给他设置中断,并且每次循环都去判断中断信号,如下图打印结果,线程运行五秒后会中断并且抛出异常。

    run里面处理中断

            但是实际生产环境中,我们并不会直接去在run方法中处理业务逻辑,一般都是去调用子方法,那么我们在子方法中应该优先抛出异常,或者是正确处理中断,而不应该吞掉异常。这样处理显然是不行的。

    子方法没有正确处理而吞掉异常 子方法正确处理异常并且处理中断信号 子方法优先抛出异常由run处理中断和异常

            所以为了正确的处理中断,我们在run方法中调用其他方法的时候,首先看看子方法有没有正确优先抛出异常,否则去深入研究一下子方法有没有处理中断信息。

    1.错误的停止方式:stop方法

    stop停止RU

            如上图所示:stop在线程中的内容执行到110的时候已经停止了,想象一下生产环境中如果出现这样的事情,那么根本无法定位哪些执行了,哪些未执行,出现脏数据,会造成严重后果。所以stop()已经被废弃并且明文禁止使用。

    2.错误的停止方式:volatile 设置boolean标记位

            volatile关键字:简单的理解volatile修饰的变量是一个它在线程中可见的共享变量,且在读取volatile类型的变量时总会返回最新写入的值。

            如下图代码我们用volatile关键字标记布尔值使得线程可以被标记停止。但是这种看似可行的方法,实际上也是不可以的,当线程长时间阻塞的时候,他会失效。

    volatile 属性标记 

    线程的状态

            多线程的状态图如下:

    多线程的五种状态

    1.新建状态

            当用new操作符创建一个线程时, 例如new Thread(r),线程还没有开始运行,此时线程处在新建状态。 当一个线程处于新生状态时,程序还没有开始运行线程中的代码。

    2.就绪状态

            一个新创建的线程并不自动开始运行,要执行线程,必须调用线程的start()方法。当线程对象调用start()方法即启动了线程,start()方法创建线程运行的系统资源,并调度线程运行run()方法。当start()方法返回后,线程就处于就绪状态。

            处于就绪状态的线程并不一定立即运行run()方法,线程还必须同其他线程竞争CPU时间,只有获得CPU时间才可以运行线程。因为在单CPU的计算机系统中,不可能同时运行多个线程,一个时刻仅有一个线程处于运行状态。因此此时可能有多个线程处于就绪状态。对多个处于就绪状态的线程是由Java运行时系统的线程调度程序(thread scheduler)来调度的。

    3.运行状态

            当线程获得CPU时间后,它才进入运行状态,真正开始执行run()方法。

    4.阻塞状态

    线程运行过程中,可能由于各种原因进入阻塞状态:

            1>线程通过调用sleep方法进入睡眠状态;

            2>线程调用一个在I/O上被阻塞的操作,即该操作在输入输出操作完成之前不会返回到它的调用者;

            3>线程试图得到一个锁,而该锁正被其他线程持有;

            4>线程在等待某个触发条件;

    5.死亡状态

    有两个原因会导致线程死亡:

            1> run方法正常退出而自然死亡。

            2>一个未捕获的异常终止了run方法而使线程猝死。为了确定线程在当前是否存活着(就是要么是可运行的,要么是被阻塞了),需要使用isAlive方法。如果是可运行或被阻塞,这个方法返回true; 如果线程仍旧是new状态且不是可运行的, 或者线程死亡了,则返回false。

    线程安全

    1.什么是线程安全?

            当多个线程同时共享,同一个全局变量或静态变量,做写的操作时,可能会发生数据冲突问题,也就是线程安全问题。但是做读操作是不会发生数据冲突问题。

            案例:如下图代码模拟售票窗口售票100张。用一个实例下的两个线程同时去操作同100张票,会导致重复卖或者超卖。

    多线程超卖

    2.解决办法之synchronized关键字

    2.1 synchronized的作用

            synchronized是Java的关键字,能保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。是最基本的互斥同步手段,可以用于代码块同步,也可以直接在方法上同步。

            如下代码:用了synchronized同步代码块将操作共享变量的地方包裹起来。应对模拟售票的超卖问题:

    synchronized同步代码块

    2.2 synchronized之对象锁

            synchronized默认时使用的本对象的实例 this 来充当锁的,当场景比较复杂的话需要多把锁来保证不同的保证对象的时候,就需要自己来创建对象锁。

            如下图代码:我们创建了两个对象锁去分别运行不同的代码块,通过打印结果发现,两把锁的代码块在并行执行。并没有先后顺序,当我们把锁都换成lock1后,他会顺序打印。

    不同 对象锁的应用

    2.3 synchronized之类锁

            概念:一个Java类可能对有好多个实例对象,但是只有一个Class对象。它有两种应用,分别是静态方法应用synchronized和synchronized(类.class)。

            如图:我们创建了两个线程,但不是同一个实例下的,当我们用this锁的时候,并没有做到同步,当我们使用synchronized静态方法锁的时候,按顺序打印执行了。

    synchronized静态方法同步

    下面这段代码是用 synchronized 类锁来实现同步

    synchronized 类锁同步

            总结:通过上面代码演示,本质上是看线程争抢多少把锁,如果是同一把锁就同步,若是不同的锁就并行。另外,synchronized只适用于单个JVM,无法适用于集群环境,集群环境我们使用分布式锁(redis、zookeeper...)。

    2.4 synchronized 的缺陷

    1、锁的释放情况少

            当线程获取到锁,在执行过程中,其他线程也想用该锁时,只能等待当前线程释放。释放情况少,体现在两种情况:执行完毕、异常(JVM将锁释放)。

            如果要等待IO这种耗时操作或者sleep,有不会中途释放锁,其他线程只能干巴巴的等着,非常影响程序执行的效率。需要一种机制,遏制让一直在等待,又影响其他线程执行的这些情况。lock可以。

    2、试图获得锁时不能设定超时。

    3、不能中断一个正在试图获得锁的线程。

    3.解决办法之Lock锁

            在jdk1.5之后,并发包中新增了 Lock 接口(以及相关实现类)用来实现锁功能,Lock 接口提供了与 synchronized 关键字类似的同步功能,相较于synchronized 增加了一些其他的功能(超时、试图获得锁、判断是否获得锁等)使得其更加灵活,但需要在使用时手动获取锁和释放锁。

            如下图我们用Lock实现了synchronized 的功能。

    Lock锁示例

    线程池

            线程池是指在初始化一个多线程应用程序过程中创建一个线程集合,然后在需要执行新的任务时重用这些线程而不是新建一个线程。线程池中线程的数量通常完全取决于可用内存数量和应用程序的需求。然而,增加可用线程数量是可能的。线程池中的每个线程都有被分配一个任务,一旦任务已经完成了,线程回到池子中并等待下一次分配任务。

    基于以下几个原因在多线程应用程序中使用线程是必须的:

            1.线程池改进了一个应用程序的响应时间。由于线程池中的线程已经准备好且等待被分配任务,应用程序可以直接拿来使用而不用新建一个线程。

            2.线程池节省了CLR 为每个短生存周期任务创建一个完整的线程的开销并可以在任务完成后回收资源。

            3.线程池根据当前在系统中运行的进程来优化线程时间片。

            4.线程池允许我们开启多个任务而不用为每个线程设置属性。

            5.线程池允许我们为正在执行的任务的程序参数传递一个包含状态信息的对象引用。

            6.线程池可以用来解决处理一个特定请求最大线程数量限制问题。

    Java通过Executors(jdk1.5并发包)提供四种线程池,分别为:

            newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

            newFixedThreadPool创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。

            newScheduledThreadPool创建一个定长线程池,支持定时及周期性任务执行。

            newSingleThreadExecutor创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

    相关文章

      网友评论

          本文标题:JAVA并发编程核心知识

          本文链接:https://www.haomeiwen.com/subject/tonidltx.html