多线程入门,什么是多线程

多线程入门,什么是多线程

主要内容来自《Java核心技术 卷Ⅰ》的多线程这一章,原书这一部分有近百页,建议有精力的伙伴直接看原书。

1. 多线程概念


​ 多线程程序在较低的层次上扩展了多任务的概念:一个程序同时执行多个任务。通常,每一个任务称为一个线程,可以同时运行一个以上线程的程序成为多线程程序。与进程相比较,线程更”轻量级“,创建、撤销一个线程比启动新进程的开销要小得多。

​ 在实际应用种,多线程非常有用。例如,一个浏览器可以同时下载几副图片,一个Web服务器需要同时处理几个并发的请求。此外,利用多线程可以异步的执行一些开销较大的操作,如上传大文件等,以此防止主线程阻塞。

2. 中断线程


​ 当线程的run方法执行方法体中最后一条语句后,并经由执行return语句返回时,或者出现了在方法中没有捕获的异常时,线程将终止。

​ 没有可以强制线程终止的方法,使用interrupt方法可以请求终止线程。当对一个线程调用interrupt方法时,线程的中断状态将被置位。这是每一个线程都具有的boolean标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。(阻塞的线程无法检测中断状态)

中断一个线程不过是引起它的注意,被中断的线程可以决定如何响应中断。

3. 线程状态


线程可以有如下6种状态:

  • New(新创建)
  • Runnable(可运行)
  • Blocked(被阻塞)
  • Waiting(等待)
  • Timed waiting(计时等待)
  • Terminated(被终止)

线程状态如图:
在这里插入图片描述

3.1 新创建线程

​ 当用new操作符创建一个新线程时,如new Thread®,改线程还没有开始运行。当一个线程处于新创建状态时,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做(可以通过线程池减少这一步的开销)。

3.2 可运行线程

​ 一旦调用start方法,则线程处于runnable状态。==一个可运行的线程可能正在运行也可能没有运行,==这取决于操作系统给线程提供运行的时间。一旦一个线程开始运行,它不必始终保持运行。事实上,中断运行中的线程是为了让其他线程获得运行机会。抢占式调度系统给每一个可运行线程一个时间片来执行任务,操作系统在这个线程的时间片用完后剥夺该线程的运行权,并给另一个线程运行机会。操作系统会考虑线程的优先级来选择下一个线程。

3.3 阻塞线程和等待线程

当线程处于被阻塞或等待状态时不运行任何代码且消耗最少的资源,直到线程调度器重新激活它。

扫描二维码关注公众号,回复: 12869383 查看本文章
  • 当一个线程试图获取一个内部的对象锁,而该锁被其他线程持有,则该线程进入阻塞状态。当所有其他线程释放该锁,且线程调度器允许这个线程持有该锁时,线程将编程非阻塞状态。
  • 当一个线程等待另一个线程通知调度器某个条件时,这个线程进入等待状态。
  • 某些方法会有一个超时参数,调用这些方法会导致线程进入计时等待状态。这一状态将一直保持到超时期满或者接收到适当的通知。

3.4 被终止的线程

线程会因如下两个原因之一而被终止:

  • 因为run方法正常退出而自然死亡。
  • 因为一个没有捕获的异常终止了run方法而意外死亡。

可以调用线程的stop方法杀死一个线程,该方法抛出ThreadDeath错误对象,由此杀死线程。但是,stop方法已过时,千万不要在自己的代码中调用这个方法!

4. 线程属性


线程的各种属性包括:线程优先级、守护线程、线程组以及处理未捕获异常的处理器。

4.1 线程优先级

​ 在Java程序设计语言中,每一个线程都有一个优先级。默认情况下,一个线程将继承它的父线程的优先级,可以用setPriority方法提高或降低任意线程的优先级(最低为1,最高为10)。构建程序时,功能的正确性不应依赖于优先级。

使用优先级时应避免一个错误:如果有几个高优先级的线程没有进入非活动状态,低优先级的线程可能永远也不能执行。每当调度器决定运行一个新线程时,首先会在具有高优先级的线程中进行选择,这会使低优先级的线程完全饿死。

4.2 守护线程

​ 可以通过调用t.setDaemon(ture);将线程转换为守护线程。守护线程的唯一用途是为其他线程提供服务(计时线程就是一个例子)。当只剩下守护线程时,虚拟机就会退出。因此,守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。

void setDaemon(boolean isDaemon)方法:标识该线程为守护线程或用户线程,这一方法必须在线程启动之前调用。

4.3 未捕获异常处理器

​ 线程的run方法不能抛出任何被检测的异常,但是,不被检测的异常会导致线程终止。此时,不需要使用catch子句来处理可以被传播的异常,异常会在线程死亡之前被传递到一个用于未捕获异常的处理器。

​ 该处理器必须属于一个实现Thread.UncaughtExceptionHandler接口的类,这个接口只有一个方法:

void uncaughtException(Thread t,Throwable e)

​ 可以用setUncaughtExceptionHandler方法为任何线程安装一个处理器,也可以用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有线程安装一个默认的处理器。如果不安装默认的处理器,则默认的处理器为空,如果不为独立的线程安装处理器,则该线程处理器就是改线程的ThreadGroup(线程组)对象。

线程组是一个可以统一管理的线程集合。默认情况下,创建的所有线程属于相同的线程组,但也可能会建立其他的组。现在引入了更好的用于线程集合操作的特性,因此,建议不要在自己的程序中使用线程组。

5. 同步


​ 在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,则会产生讹误的对象。这一情况通常称为竞争条件

5.1 锁对象

​ 有两种机制防止代码块受并发访问的干扰。Java语言提供了一个synchornized关键字达到这一目的,并且Java SE 5.0引入了ReentrantLock类。

5.2 条件对象

​ 通常,线程进入临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理哪些已经获得了一个锁但是却不能做有用工作的线程。

5.3 synchronized关键字

锁和条件的关键之处:

  • 锁可以用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
  • 锁可以管理试图进入被保护代码段的线程。
  • 锁可以拥有一个或多个相关的条件对象。
  • 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

内部锁和条件存在一些局限,包括:

  • 不能中断一个正在试图获得锁的线程。
  • 试图获得锁时不能设定超时。
  • 每个锁仅有单一的条件,可能似乎不够的

实际编码中关于这一部分的建议:

  • 最好既不使用Lock/Condition也不使用synchronized关键字(所以这一段略过了synchronized关键字的一些内容,具体使用很简单,想了解可以直接百度)。许多情况下可以直接使用java.util.concurrent包中的对应机制,它会为我们处理所有的加锁。
  • 如果synchronized关键字很适合我们的程序,那么应该尽可能多的使用它,这样可以减少编写的代码量,较少出错的机率
  • 只有特别需要Lock/Condition结构提供的独有特性时才使用它们。

5.4 同步阻塞

	每一个Java对象有一个锁,线程可以通过调用同步方法获得锁,也可以通过进入一个同步阻塞获得锁。

​ 有时程序员使用一个对象的锁来实现额外的原子操作,实际上称为客户端锁定

5.5 监视器

​ 使用监视器可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。监视器具有如下特性:

  • 监视器是只包含私有域的类。
  • 每个监视器类的对象有一个相关的锁。
  • 使用该锁对所有的方法进行加锁。换句话说,如果客户端调用obj.method(),那么obj对象的锁是在方法调用开始时自动获得,并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对象操作时,没有其他线程能访问该域。
  • 该锁可以有任意多个相关条件。

如果一个方法用synchronized关键字声明,那么它的表现就像时一个监视器方法。通过调用wait/notifyAll/notify来访问条件变量。然而,在下述的3个方面Java对象不同于监视器,从而使得线程的安全性下降:

  • 域不要求必须时private。
  • 方法不要求必须是synchronized。
  • 内部锁对客户是可用的。

5.6 Volatile域

​ volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。

Volatile变量不能提供原子性。例如,方法:

public void flipDone(){done =!done;}

不能确保翻转域中的值。

5.7 final变量

​ 除非使用锁或volatile修饰符,否则无法从多个线程安全地读取一个域。但当一个共享域声明为final时,可以安全地访问这个域。如:

final Map<String, Double>accounts = new HashMap<>();

​ 其他线程会在构造函数完成构造之后才看到这个accounts变量。如果不使用final,就不能保证其他线程看到地时accounts更新后地值,它们可能都只是看到null,而不是新构造地HashMap。当然,==对这个映射表地操作并不是线程安全的。==如果多个线程在读写这个映射表,仍然需要进行同步。

5.8 原子性

​ 假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为volatile。

5.9 死锁

​ 锁和条件不能解决多线程中的所有问题。有可能会因为每一个线程要等待更多的锁而导致所有线程都被阻塞,这样的状体称为死锁。Java编程语言中没有任何东西可以避免或打破这种死锁现象,必须仔细设计程序,以确保不会出现死锁。

5.10 线程局部变量

​ 有时可能要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例。假设有一个静态变量:

public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");

​ 如果两个线程都执行以下操作:

String dateStamp = dateFormat.format(new Date());

​ dateFormat使用的内部数据结构可能会被并发的访问所破坏,此时使用同步的开销很大,在需要时构造一个局部SimpleDateFormat对象也十分浪费,要为每个线程构造一个实例,可以使用以下代码:

public static final ThreadLocal<SimpleDateFormat>dateFormt = new ThreadLocal<SimpleDateFormat>(){
    
    
    protected SimpleDateFormat initialValue(){
    
    
        return new SimpleDateFormat("yyyy-MM-dd");
    }
};

​ 要访问具体的格式化方法,可以调用:

String dateStamp = dateFormat.get().format(new Date());

​ 使用ThreadLocal辅助类为各个线程提供一个单独的生成器,不过Java SE 7还另外提供了一个更方便的类,调用方法如下:

int random = ThreadLocalRandom.current().nextInt(upperBound);

​ ThreadLocalRandom.current()调用会返回特定于当前线程的Random类实例。

5.11 锁测试与超时

​ 线程在调用lock方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。tryLock方法试图申请一个锁,在成功获得锁后返回true,否则,立即返回false,而且线程可以立即离开去做其他事情。lock方法不能被中断,如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁,那么lock方法就无法终止。

​ 然而,如果调用带有超时参数地tryLock,那么如果线程在等待期间被中断,将抛出InterruptedException异常。这是一个非常有用地特性,因为允许程序打破死锁。也可以调用lockInterruptibly方法,它就相当于一个超时设置为无线地tryLock方法。

6. 阻塞队列


​ 对于许多线程问题,可以通过使用一个或多个队列以优雅且安全地方式将其形式化。生产者线程向队列插入元素,消费者线程则取出它们。使用队列,可以安全地从一个线程向另一个线程传递数据。

​ 当试图向队列添加元素而队列已满,或是想从队列移出元素而队列为空的时候,阻塞队列导致线程阻塞。在协调多个线程之间的合作时,阻塞队列是一个有用的工具。工作者线程可以周期性地将中间结果存储在阻塞队列中。其他的工作者线程移出中间结果并进一步加以修改。队列会自动地平衡负载。如果第一个线程集运行地比第二个慢,第二个线程集在等待结果时会阻塞。如果第一个线程集运行得快,它将等待第二个队列集赶上来。

方法 正常动作 特殊情况下的动作
add 添加一个元素 如果队列满,则抛出IllegalStateException异常
element 返回队列的头元素 如果队列空,抛出NoSuchElementException异常
offer 添加一个元素并返回true 如果队列满,返回false
peek 返回队列的头元素 如果队列空,则返回null
poll 移出并返回队列的头元素 如果队列空,则返回null
put 添加一个元素 如果队列满,则阻塞
remove 移出并返回头元素 如果队列空,则抛出NoSuchElementException异常
take 移出并返回头元素 如果队列空,则阻塞

​ 阻塞队列方法分为以下3类,这取决于当队列满或空时它们地相应方式:如果将队列当作线程管理工具来使用,将要用到put和take方法。当试图向满的队列中添加或从空的队列中移出元素时,add、remove和element操作抛出异常。在一个多线程程序中,队列会在任何时候空或满,因此一定要使用offer、poll和peek方法作为替代。这些方法如果不能完成任务,只是给出一个错误提示而不会抛出异常。

poll和peek方法返回空来指示失败。因此,向这些队列中插入null值是非法的!

7. 线程安全的集合


​ 如果多线程要并发地修改一个数据结构,如散列表,那么很可能会破坏这个数据结构。可以通过提供锁来保护共享数据结构,但是选择线程安全的实现作为替代会更容易。

7.1 高效的映射表、集合和队列

​ java.util.concurrent包提供了映射表、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet和ConcurrentLinkedQueue。这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化。

​ 与大多数集合不通,这些集合的size方法不必在常量时间内操作。确定这样的集合当前的大小通常需要遍历。

​ 这些集合返回弱一致性的迭代器。这意味着迭代器不一定能放映出它们被构造之后的所有的修改。但是,它们不会将同一个值返回两次,也不会抛出ConcurrentModificationException异常。

7.2 写数组的拷贝

​ CopyOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合,其中所有的修改线程将对底层数组进行复制。当构建一个迭代器的时候,它包含一个对当前数组的引用。如果数组后来被修改了,迭代器仍然引用旧数组。因此,旧的迭代器拥有一致的(可能已过时的)视图,访问它无须任何同步开销。

7.3 较早的线程安全集合

​ 从Java的初始版本开始,Vector和HashTable类就提供了线程安全的动态数组和散列表的实现。现在这些类被弃用了,取而代之的是ArrayList和HashMap类。这些类不是线程安全的,而集合库中提供了不同的机制。任何集合类可以通过使用同步包装器变成线程安全的:

List<E> synchArrayList = Collections.synchronizedList(new ArrayList<>());
Map<K, V> synchHashMap = Collections.synchronizedMap(new HashMap<K, V>());

​ 应该尽可能避免没有任何线程通过原始的非同步方法访问数据结构,因此,在需要使用线程安全的集合时,最好直接简单的构造一个集合然后直接传递给包装器来获得一个线程安全的集合。

最好使用java.util.concurrent包中定义的集合,而不是使用同步包装器中的。

例外:当要使用的数组列表经常被修改时,同步的ArrayList效率会高于CopyOnWriteArrayList。

8. Callable与Future


​ Runnable封装一个异步运行的任务,可以把它想象成一个没有参数和返回值的异步方法。Callable与Runnable类似,但是Callable有返回值。Callable接口是一个参数化的类型,只有一个方法call。

public interface Callable<V>
{
    
    
    V call() throws Exception;
}
//类型参数是返回值的类型,如Callable<Integer>表示一个最终返回Integer对象的异步计算。

​ Future保存异步计算的结果。Future对象的所有者在结果计算好之后就可以获得它。

public interface Future<V>
{
    
    
    V get() throws ...;
    V get(long timeout,TimeUnit unit)throws ...;
    void cancel(boolean mayInterrupt);
    boolean isCancelled();
    boolean isDone();
}
//第一个get方法的调用被阻塞,直到计算完成。
//如果在计算完成前,第二个方法的掉用超时,则抛出一个TimeoutException异常。
//如果运行该计算的线程被中断,则两个方法都将抛出InterruptedException异常。
//如果计算已经完成,那么get方法立即返回。
//如果计算还在进行,调用isDone方法返回false,否则返回true。
//可以用cancel方法取消计算。如果计算还没开始,则它被取消不再开始。如果计算正在运行,则如果mayInterrupt参数为true,则中断计算。

9. 执行器


9.1 线程池

​ 构建一个新的线程是有一定代价的,因为涉及与操作系统的交互。如果程序中创建了大量的生命期很短的线程,则应该使用线程池。一个线程池中包含许多准备运行的空闲线程。将Runnable对象交给线程池,就会有一个线程调用run方法。当run方法退出时,线程不会死亡,而是在池中准备为下一个请求提供服务。

​ 使用线程池可以减少并发线程的数目。创建大量线程会大大降低性能甚至使虚拟机崩溃。

​ 执行器类(Executor)提供了一些静态工厂方法用以构建线程池。

方法 描述
newCachedThreadPool 必要时创建新线程;空闲线程会被保留60秒
newFixedThreadPool 该池包含固定数量的线程;空闲线程会一直被保留
newSingleThreadExecutor 只有一个线程的线程池,该线程顺序执行每一个提交的任务
newScheduledThreadPool 用于预定执行而构建的固定线程池,替代java.util.Timer
newSingleThreadScheduledExecutor 用于预定执行而构建的单线程”池“

9.2 预定执行

​ ScheduledExecutorService接口具有为预定执行或重复执行任务而设计的方法。它是一种允许使用线程池机制的java.util.Timer的泛化。Executors类的newScheduledThreadPool和newSingleThreadScheduledExecutor方法将返回实现了ScheduledExecutorService接口的对象。

10. 同步器


​ java.util.concurrent包包含了几个能帮助人们管理相互合作的线程集的类:

用途 适用场合
CyclicBarrier 允许线程集等待直至其中预定数目的线程达到一个公共障栅,然后可以选择执行一个处理障栅的动作 当大量的线程需要在它们的结果可用之前完成时
CountDownLatch 允许线程集等待直到计数器减为0 当一个或多个线程需要等待直到指定数目的事件发生时
Exchanger 允许两个线程在要交换的对象准备好时交换对象 当两个线程工作在统一数据结构的两个实例上的时候,一个向实例添加数据而另一个从实例清除数据

​ 这些机制具有为线程之间的共用集结点模式提供的”与之功能“。如果有一个相互合作的线程集满足这些行为模式之一,那么应该直接重用合适的库类而不是提供手工的锁与条件的集合。

10.1 信号量

​ 一个信号量管理多个许可证。为了通过信号量,线程通过调用acquire请求许可。其他线程可以通过调用release释放许可。

10.2 倒计时门栓

​ 一个倒计时门栓让一个线程集等待直到计数变为0.倒计时门栓是一次性的,一旦计数为0,就不能再重用了。

10.3 障栅

​ CyclicBarrier类实现了一个集结点:障栅。考虑大量线程运行在一次计算的不同部分的情形。当所有部分都准备好时,需要把结果组合在一起。当一个线程完成了它的那部分任务后,则让它运行到障栅处。一旦所有的线程都到达了这个障栅,障栅就撤销,线程就可以继续运行。

10.4 交换器

​ 当两个线程在同一个数据缓冲区的两个实例上工作的时候,就可以使用交换器。典型的情况是:一个线程向缓冲区填入数据,另一个线程消耗这些数据。当它们都完成以后,相互交换缓冲区。

10.5 同步队列

​ 同步队列是一种将生产者和消费者线程配对的机制。当一个线程调用SynchronousQueue的put方法时,它会阻塞直到另一个线程调用take方法为止,反之亦然。与交换器不同,同步队列的数据仅仅沿一个方向传递,从生产者到消费者。

猜你喜欢

转载自blog.csdn.net/qq_42026590/article/details/111911838