21.1 Java 多线程编程基础

简介: 进程和线程进程: 一个正在执行的程序。每个进程执行都有一个执行顺序,该顺序是一个执行路径,或叫一个控制单元。一个进程至少有一个线程。线程:就是进程中的一个独立的控制单元. 线程控制这进程的执行。多进程的缺点:进程切换开销大;进程间的通信不方便。

进程和线程



进程: 一个正在执行的程序。每个进程执行都有一个执行顺序,该顺序是一个执行路径,或叫一个控制单元。一个进程至少有一个线程。


线程:就是进程中的一个独立的控制单元. 线程控制这进程的执行。


多进程的缺点:进程切换开销大;进程间的通信不方便。


多线程: 指的是在单个程序中可以同时运行多个不同的线程,执行不同的任务,线程切换的开销小 。


在 Java 中,当我们启动 main 函数时其实就启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。


线程创建与运行



Java 中表示线程,用到了 Thread 类。其构造方法如下:

public Thread();
public Thread (ThreadGroup group,Runnable target,String name);     
public Thread(Runnable target);
public Thread(Runnable target,String name);
public Thread(String name);
public Thread(ThreadGroup group,Runnable target);
public Thread(ThreadGroup group,String name);


线程创建的三种方式


  • 继承 Thread 类实现多线程


  • 实现 Runnable 接口编写多线程,将一个 Runnable 接口对象传递给线程,线程在调度时将自动调用 Runnable 接口对象的 run方法。


  • 使用 FutureTask 方式。创建的 FutrueTask 对象作为任务创建了一个线程并且启动它,最后通过 futureTask.get()等待任务执行完毕并返回结果。


使用线程



程序中不要直接调用 run 方法,而是调用线程对象的 start()方法启动线程,让其进入可调度状态,线程将在合适时机获得调度自动执行 run()方法。


若想有效使用多线程代码,要对监视器和锁有些基本的认识。你需要知道的要点如下。

• 同步是为了保护对象的状态和内存,而不是代码。


• 同步是线程间的协助机制。一个缺陷就可能破坏这种协助模型,导致严重的后果。


• 获取监视器只能避免其他线程再次获取这个监视器,而不能保护对象。


• 即便对象的监视器锁定了,不同步的方法也能看到(和修改)不一致的状态。


• 锁定 Object[] 不会锁定其中的单个对象。


• 基本类型的值不可变,因此不能(也无需)锁定。


• 接口中声明的方法不能使用 synchronized 修饰。


• 内部类只是语法糖,因此内部类的锁对外层类无效(反过来亦然)。


• Java 的锁可重入(reentrant)。这意味着,如果一个线程拥有一个监视器,这个线程遇到具有同一个监视器的同步代码块时,可以进入这个代码块。


wait() 和 notify() 方法必须在 synchronized 修饰的方法或代码块中使用,因为只有临时把锁放弃,这两个方法才能正常工作。


线程调度与优先级(体现了多线程的随机性)



Java 采用抢占式调度策略,下面几种情况下,当前线程会放弃CPU:


  1. 当前时间片用完;


  1. 线程在执行时调用了yield()sleep()方法主动放弃;


  1. 进行 I/O 访问,等待用户输入,导致线程阻塞;或者为等候一个条件变量,线程调用wait方法;


  1. 有高优先级的线程参与调度。


线程的优先级


用数字来表示,范围从 1~10。主线程的默认优先级为 5。

// 三个常用的优先级
    Thread.MIN_PRIORITY=1          
    Thread.NORM_PRIORITY=5     
    Thread.MAX_PRIORITY=10


 
         


线程的生命周期


Java 做了很多工作,力求把这些细节抽象化。Java 提供了一个名为 Thread.State 的枚举类型,囊括了操作系统看到的线程状态。 Thread.State 中的值概述了一个线程的生命周期。


image.png


常见的线程状态


  • NEW 新建状态
    已经创建线程,但还没在线程对象上调用 start() 方法。所有线程一开始都处于这个
    状态。


  • RUNNABLE 运行中
    当操作系统调度线程时可以运行或该线程正在运行。


  • BLOCKED 阻塞状态
    因为它在等待获得一个锁,以便进入声明为 synchronized 的方法或代码块。


  • WAITING
    线程中止运行,因为它调用了 Object.wait() 或 Thread.join() 方法。
    在 sleep 和 wait 时。


  • TIMED_WAITING
    线程中止运行,因为它调用了 Thread.sleep() 方法,或者调用了 Object.wait() 或Thread.join() 方法,而且传入了超时时间。


  • TERMINATED
    线程执行完毕。线程对象的 run() 方法正常退出,或者抛出了异常。


Thread 类中常用方法



  • setName()getName()
    开发者使用这两个方法设定或取回单个线程的名称。为线程起名字是个好习惯,因为这样调试时更方便,尤其是使用 jvisualvm 等工具。


  • isAlive()
    返回线程是否还“活着”。线程被启动后,run 方法运行结束前,返回值都是 true。


  • start()
    这个方法用来创建一个新应用线程,然后再调用 run() 方法调度这个线程,开始执行。正常情况下,执行到 run() 方法的末尾或者执行 run() 方法中的一个 return 语句后,线程就会结束运行。


  • interrupt()
    中断线程。如果调用 sleep() 、 wait() 或 join() 方法时阻塞了某个线程,那么在表示这个线程的Thread 对象上调用 interrupt() 方法,会让这个线程抛出 InterruptedException 异常(并把线程唤醒)。如果线程中涉及可中断的 I/O 操作,那么这个 I/O 操作会终止,而且线程会收到 ClosedByInterruptException 异常。即便线程没有从事任何可中断的操作,线程的中断状态也会被设为 true。


  • join()
    是一个对象方法,可以让调用 join 的当前线程一直处于等待状态,等待直到该线程结束。可以把这个方法理解为一个指令,在其他线程结束之前,当前线程不会继续向前运行。貌似只在start()后才生效。


  • setDaemon()
    用户线程是这样一种线程,只要它还“活着”,进程就无法退出——这是线程的默认行为。有时,程序员希望线程不阻止进程退出——这种线程叫守护线程(可以理解为后台线程)。一个线程是守护线程还是用户线程,由 setDaemon() 方法控制。这个方法必须在线程 start 前调用,否则会抛出 IllegalThreadStateException。


  • setUncaughtExceptionHandler()
    线程因抛出异常而退出时,默认的行为是打印线程的名称、异常的类型、异常消息和堆栈跟踪。如果这么做还不够,可以在线程中自定义的处理程序,处理未捕获的异常。


  • yield() 这也是一个静态方法,调用该方法,是告诉操作系统的调度器:我现在不着急占用CPU,你可以先让其他线程运行。不过,这对调度器也仅仅是建议,调度器如何处理是不一定的,它可能完全忽略该调用。


  • sleep() :Thread 有一个静态的 sleep 方法,调用该方法会让当前线程睡眠指定的时间。

@Override
public void run() {
    while (!Thread.currentThread().isInterrupted() && more work to do) {
        try {
            ...
            sleep(delay);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();//重新设置中断标示
        }
    }
}


sleep 与 yield 方法的区别

当线程调用 sleep 方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用 yield 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。


Thread 类弃用的方法


Thread 类除了有一些有用的方法之外,还有一些危险的方法,开发者不应该使用。这些方法是 Java 线程 API 原来提供的,但很快就发现不适合开发者使用。可惜的是,因为 Java要向后兼容,所以不能把这些方法从 API 中移除。


  • stop()
    如若不违背并发安全的要求,几乎不可能正确使用 Thread.stop() ,因为 stop() 方法会立即“杀死”线程,不会给线程任何机会把对象恢复成合法状态。这和并发安全等原则完全相悖,因此绝对不能使用 stop() 方法。


  • suspend() 、 resume() 和 countStackFrames()
    调用 suspend() 方法挂起线程时,不会释放这个线程拥有的任何一个监视器,因此,如果其他线程试图访问这些监视器,这些监视器会变成死锁。其实,这种机制会导致死锁之间的条件竞争,而且 resume() 会导致这几个方法不能使用。


  • destroy()
    这个方法一直没有实现,如果实现了,会遇到与 suspend() 方法一样的条件竞争。开发者始终应该避免使用这些弃用的方法。为了达到上述方法的预期作用,Java 开发了一些安全的替代模式。前面提到的“关闭前一直运行”模式就是这些模式的一例。


可见性和可变性



在 Java 中,其实一个进程中的每个 Java 应用线程都有自己的栈(和局部变量),不过这些线程共用同一个堆,因此可以轻易在线程之间共享对象,毕竟需要做的只是把引用从一个线程传到另一个线程.


image.png


  • 由此引出 Java 的一个一般设计原则——对象默认可见。如果我有一个对象的引用,就可以复制一个副本,然后将其交给另一个线程,不受任何限制。Java 中的引用其实就是类型指针,指向内存中的一个位置,而且所有线程都共用同一个地址空间,所以默认可见符合自然规律。


  • 除了默认可见之外,Java 还有一个特性对理解并发很重要——对象是可变的(mutable),对象的内容(实例字段的值)一般都可以修改。使用 final 关键字可以把变量或引用声明为常量,但这种字段不属于对象的内容。


这两个特性(跨线程下对象的可见性和可变性)结合在一起,大大增加了理解 Java 并发编程的难度。


并发编程的安全性



原子操作


在 Java 中, 对非 long 和 double 类型的域的读取和写入操作是原子操作。对象引用的读取和写入操作也是原子操作。在多线程程序中使用 long 和 double 型的共享变量时,需要把变量申明为 volatile 以保证读取和写入操作的完整性.


如果我们想编写正确的多线程代码,得让程序满足一个重要的条件,


即:在一个程序中,不管调用什么方法,也不管操作系统如何调度应用线程,一个对象看到的任何其他对象都不处于非法或不一致的状态,这样的程序才称得上是安全的多线程程序 。


互斥(mutual exclusion)和状态保护


临界资源问题


只要修改或读取对象的过程中,对象的状态可能不一致,这段代码就要受到保护。为了保护这种代码,Java 平台只提供了一种机制:互斥


Java 平台会为它创建的每个对象记录一个特殊的标记,这个标记叫监视器(monitor)。Java 使用 synchronized 指明对应的监视器(或叫锁)。


同步是保护状态的一种协助机制,因此非常脆弱。一个缺陷(需要使用synchronized 修饰的方法却没有使用)就可能为系统的整体安全性带来灾难性的后果。


  • 对象如同锁,持有锁的线程可以在同步中执行。


  • 没有持有锁的线程即使获取 CPU 的执行权,也进不去,因为没有获取锁。


同步的前提:


  1. 必须要有两个或者以上的线程


  1. 必须要多个线程使用同一个锁


好处:解决了多线程的安全问题


弊端:多个线程需要判断锁,较为消耗资源.


synchronized 关键字


Java 为开发者提供了 synchronized 关键字。这个关键字可以用在代码块或方法上,使用时,Java 平台会限制访问代码块或方法中的代码。


之所以使用 synchronized 这个词作为“需要临时互斥存储”的关键词,除了说明需要获取监视器之外,还表明进入代码块时,JVM 会从主内存中重新读取对象的当前状态。类似地,退出 synchronized 修饰的代码块或方法时,JVM 会刷新所有修改过的对象,把新状态存入主内存。


执行 synchronized 实例方法的过程大致如下:


1)尝试获得锁,如果能够获得锁,继续下一步,否则加入等待队列,阻塞并等待唤醒。


2)执行实例方法体代码。


3)释放锁,如果等待队列上有等待的线程,从中取一个并唤醒,如果有多个等待的线程,唤醒哪一个是不一定的,不保证公平性。


synchronized的实际执行过程比这要复杂得多,而且Java虚拟机采用了多种优化方式以提高性能,但从概念上,我们可以这么简单理解。


因为 synchronized 关键字把代码包围起来,所以很多开发者认为,Java 的并发和代码有关。有些资料甚至把 synchronized 修饰的块或方法中的代码称为 临界区 ,还认为临界区是并发的关键所在。其实不然,其实我们要防范的是数据的不一致性


synchronized 的使用


synchronized 可用于声明在方法上,称之为同步方法,也可用于包装代码块。

synchronized(对象) {
    需要被同步的代码
}


synchronized 使用常见误区


synchronized 保护的是对象,对实例方法,保护的是当前实例对象 this,对静态方法,保护的是类对象。实际上,每个对象都有一个锁和一个等待队列,类对象也不例外。

被保护的代码块过多. 比如一个方法中只有少数几行代码访问共享变量, 却把整个方法声明为 synchronized. 这样会影响程序的性能, 正确的做法是把需要同步的代码块用 synchronized 代码块包围即可。


synchronized 静态方法和 synchronized 实例方法保护的是不同的对象,不同的两个线程,可以一个执行 synchronized 静态方法,另一个执行 synchronized 实例方法。


可重入性


synchronized 有一个重要的特征,它是可重入的,也就是说,对同一个执行线程,它在获得了锁之后,在调用其他需要同样锁的代码时,可以直接调用。比如,在一个 synchronized 实例方法内,可以直接调用其他 synchronized 实例方法。可重入是一个非常自然的属性,应该是很容易理解的,之所以强调,是因为并不是所有锁都是可重入的,后续章节我们会看到不可重入的锁。可重入是通过记录锁的持有线程和持有数量来实现的,当调用被 synchronized 保护的代码时,检查对象是否已被锁,如果是,再检查是否被当前线程锁定,如果是,增加持有数量,如果不是被当前线程锁定,才加入等待队列,当释放锁时,减少持有数量,当数量变为 0 时才释放整个锁。


volatile 关键字



如果只是为了保证内存可见性,使用 synchronized 的成本有点高,有一个更轻量级的方式,那就是给变量加修饰符 volatile。这个关键字指明,应用代码使用字段或变量前,必须重新从主内存读取值。同样,修改使用 volatile 修饰的值后,在写入变量之后,必须存回主内存。


volatile 关键字的主要用途之一是在“关闭前一直运行”模式中使用。编写多线程程序时,如果外部用户或系统需要向处理中的线程发出信号,告诉线程在完成当前作业后优雅关闭线程,那么就要使用 volatile 。这个过程有时叫作“优雅结束”模式。


当一个变量定义为 volatile 之后,将具备两种特性:


  1. 保证此变量对所有的线程的可见性,这里的“可见性”,如本文开头所述,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。


  1. 禁止指令重排序优化。有 volatile 修饰的变量,赋值后多执行了一个 “load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个 CPU 访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。


volatile 性能


volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。


死锁和避免死锁


使用 synchronized 或者其他锁,要注意死锁。所谓死锁就是类似这种现象,比如,有a、b两个线程,a持有锁A,在等待锁B,而 b 持有锁B,在等待锁 A, a 和 b 陷入了互相等待,最后谁都执行不下去。


那么为什么会产生死锁呢?学过操作系统的朋友应该都知道,死锁的产生必须具备以下四个条件。


● 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。


● 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。


● 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。


● 环路等待条件:指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合{T0, T1,T2, …, Tn} 中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,……Tn正在等待已被 T0 占用的资源。


死锁 Java 代码示例:

package qy.basic.ch21;
public class Ch21_1_DeadLockDemo {
    static class ThreadA extends Thread {
        @Override
        public void run() {
            synchronized (ThreadA.class) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (ThreadB.class) {
                }
            }
        }
    }
    static class ThreadB extends Thread {
        @Override
        public void run() {
            synchronized (ThreadB.class) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (ThreadA.class) {
                }
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        new ThreadA().start();
        new ThreadB().start();
    }
}


怎么解决呢?要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,但是学过操作系统的读者应该都知道,目前只有请求并持有环路等待条件是可以被破坏的。

造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁。


所以解决方法是应该尽量避免在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所有代码都应该按照相同的顺序去申请锁。


参考




目录
相关文章
|
17天前
|
Java 程序员
Java编程中的异常处理:从基础到高级
在Java的世界中,异常处理是代码健壮性的守护神。本文将带你从异常的基本概念出发,逐步深入到高级用法,探索如何优雅地处理程序中的错误和异常情况。通过实际案例,我们将一起学习如何编写更可靠、更易于维护的Java代码。准备好了吗?让我们一起踏上这段旅程,解锁Java异常处理的秘密!
|
1天前
|
算法 Java 调度
java并发编程中Monitor里的waitSet和EntryList都是做什么的
在Java并发编程中,Monitor内部包含两个重要队列:等待集(Wait Set)和入口列表(Entry List)。Wait Set用于线程的条件等待和协作,线程调用`wait()`后进入此集合,通过`notify()`或`notifyAll()`唤醒。Entry List则管理锁的竞争,未能获取锁的线程在此排队,等待锁释放后重新竞争。理解两者区别有助于设计高效的多线程程序。 - **Wait Set**:线程调用`wait()`后进入,等待条件满足被唤醒,需重新竞争锁。 - **Entry List**:多个线程竞争锁时,未获锁的线程在此排队,等待锁释放后获取锁继续执行。
24 12
|
5天前
|
安全 Java API
java如何请求接口然后终止某个线程
通过本文的介绍,您应该能够理解如何在Java中请求接口并根据返回结果终止某个线程。合理使用标志位或 `interrupt`方法可以确保线程的安全终止,而处理好网络请求中的各种异常情况,可以提高程序的稳定性和可靠性。
35 6
|
14天前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
14天前
|
Java 调度
Java中的多线程编程与并发控制
本文深入探讨了Java编程语言中多线程编程的基础知识和并发控制机制。文章首先介绍了多线程的基本概念,包括线程的定义、生命周期以及在Java中创建和管理线程的方法。接着,详细讲解了Java提供的同步机制,如synchronized关键字、wait()和notify()方法等,以及如何通过这些机制实现线程间的协调与通信。最后,本文还讨论了一些常见的并发问题,例如死锁、竞态条件等,并提供了相应的解决策略。
38 3
|
15天前
|
监控 Java 开发者
深入理解Java中的线程池实现原理及其性能优化####
本文旨在揭示Java中线程池的核心工作机制,通过剖析其背后的设计思想与实现细节,为读者提供一份详尽的线程池性能优化指南。不同于传统的技术教程,本文将采用一种互动式探索的方式,带领大家从理论到实践,逐步揭开线程池高效管理线程资源的奥秘。无论你是Java并发编程的初学者,还是寻求性能调优技巧的资深开发者,都能在本文中找到有价值的内容。 ####
|
18天前
|
算法 调度 开发者
多线程编程核心:上下文切换深度解析
在多线程编程中,上下文切换是一个至关重要的概念,它直接影响到程序的性能和响应速度。本文将深入探讨上下文切换的含义、原因、影响以及如何优化,帮助你在工作和学习中更好地理解和应用多线程技术。
28 4
|
18天前
|
监控 Java 数据库连接
Java线程管理:守护线程与用户线程的区分与应用
在Java多线程编程中,线程可以分为守护线程(Daemon Thread)和用户线程(User Thread)。这两种线程在行为和用途上有着明显的区别,了解它们的差异对于编写高效、稳定的并发程序至关重要。
26 2
|
4月前
|
存储 监控 Java
Java多线程优化:提高线程池性能的技巧与实践
Java多线程优化:提高线程池性能的技巧与实践
129 1
|
7月前
|
设计模式 监控 Java
Java多线程基础-11:工厂模式及代码案例之线程池(一)
本文介绍了Java并发框架中的线程池工具,特别是`java.util.concurrent`包中的`Executors`和`ThreadPoolExecutor`类。线程池通过预先创建并管理一组线程,可以提高多线程任务的效率和响应速度,减少线程创建和销毁的开销。
234 2