Appearance
多线程
线程概述
- 什么是进程?什么是线程?它们的区别?
- 进程是指操作系统中的一段程序,它是一个正在执行中的程序实例,具有独立的内存空间和系统资源,如文件、网络端口等。在计算机程序执行时,先创建进程,再在进程中进行程序的执行。一般来说,一个进程可以包含多个线程。
- 线程是指进程中的一个执行单元,是进程的一部分,它负责在进程中执行程序代码。每个线程都有自己的栈和程序计数器,并且可以共享进程的资源。多个线程可以在同一时刻执行不同的操作,从而提高了程序的执行效率。
- 现代的操作系统是支持多进程的,也就是可以启动多个软件,一个软件就是一个进程。称为:多进程并发。
- 通常一个进程都是可以启动多个线程的。称为:多线程并发。
- 多线程的作用?
- 提高处理效率。(多线程的优点之一是能够使 CPU 在处理一个任务时同时处理多个线程,这样可以充分利用 CPU 的资源,提高 CPU 的利用效率。)
- JVM规范中规定:
- 堆内存、方法区 是线程共享的。
- 虚拟机栈、本地方法栈、程序计数器 是每个线程私有的。
- 关于Java程序的运行原理
- “java HelloWorld”执行后,会启动 JVM,JVM 的启动表示一个进程启动了。
- JVM 进程会首先启动一个主线程(main-thread),主线程负责调用 main 方法。因此 main 方法是在主线程中运行的。
- 除了主线程之外,还启动了一个垃圾回收线程。因此启动 JVM,至少启动了两个线程。
- 在 main 方法的执行过程中,程序员可以手动创建其他线程对象并启动。
并发与并行
并发(concurrency)
- 使用单核CPU的时候,同一时刻只能有一条指令执行,但多个指令被快速的轮换执行,使得在宏观上具有多个指令同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干端,使多个指令快速交替的执行。
- 如上图所示,假设只有一个CPU资源,线程之间要竞争得到执行机会。图中的第一个阶段,在A执行的过程中,B、C不会执行,因为这段时间内这个CPU资源被A竞争到了,同理,第二阶段只有B在执行,第三阶段只有C在执行。其实,并发过程中,A、B、C并不是同时进行的(微观角度),但又是同时进行的(宏观角度)。
- 在同一个时间点上,一个CPU只能支持一个线程在执行。因为CPU运行的速度很快,CPU使用抢占式调度模式在多个线程间进行着高速的切换,因此我们看起来的感觉就像是多线程一样,也就是看上去就是在同一时刻运行。
并行(parallellism)
- 使用多核CPU的时候,同一时刻,有多条指令在多个CPU上同时执行。
- 如图所示,在同一时刻,ABC都是同时执行(微观、宏观)。
并发编程与并行编程
- 在CPU比较繁忙(假设为单核CPU),如果开启了很多个线程,则只能为一个线程分配仅有的CPU资源,这些线程就会为自己尽量多抢时间片,这就是通过多线程实现并发,线程之间会竞争CPU资源争取执行机会。
- 在CPU资源比较充足的时候,一个进程内的多个线程,可以被分配到不同的CPU资源,这就是通过多线程实现并行。
- 至于多线程实现的是并发还是并行?上面所说,所写多线程可能被分配到一个CPU内核中执行,也可能被分配到不同CPU执行,分配过程是操作系统所为,不可人为控制。所以,如果有人问我我所写的多线程是并发还是并行的?我会说,都有可能。
- 总结:单核CPU上的多线程,只是由操作系统来完成多任务间对CPU的运行切换,并非真正意义上的并发。随着多核CPU的出现,也就意味着不同的线程能被不同的CPU核得到真正意义的并行执行,故而多线程技术得到广泛应用。
- 不管并发还是并行,都提高了程序对CPU资源的利用率,最大限度地利用CPU资源,而我们使用多线程的目的就是为了提高CPU资源的利用率。
线程的调度策略
线程的调度模型
- 如果多个线程被分配到一个CPU内核中执行,则同一时刻只能允许有一个线程能获得CPU的执行权,那么进程中的多个线程就会抢夺CPU的执行权,这就是涉及到线程调度策略。
- 分时调度模型
所有线程轮流使用 CPU 的执行权,并且平均的分配每个线程占用的 CPU 的时间。
- 抢占式调度模型
让优先级高的线程以较大的概率优先获得 CPU 的执行权,如果线程的优先级相同,那么就会随机选择一个线程获得 CPU 的执行权,而 Java 采用的就是抢占式调用。
实现线程
第一种方式:继承 Thread
- 编写一个类继承Thread,重写run方法。
- 创建线程对象:Thread t = new MyThread();
- 启动线程:t.start();
第二种方式:实现 Runnable 接口
- 编写一个类实现Runnable接口,实现run方法。
- 创建线程对象:Thread t = new Thread(new MyRunnable());
- 启动线程:t.start();
优先选择第二种方式:因为实现接口的同时,保留了类的继承。
第二种方式也可以使用匿名内部类。
t.start()和 t.run()的本质区别?
- 本质上没有区别,都是普通方法调用。只不过两个方法完成的任务不同。
- t.run()是调用run方法。执行run方法中的业务代码。
- t.start()是启动线程,只要线程启动了,start()方法就执行结束了。
线程常用的三个方法:
- 实例方法:String getName(); void setName(String name);
- 静态方法:static Thread currentThread();
线程生命周期
线程生命周期
- 线程生命周期指的是:从线程对象新建,到最终线程死亡的整个过程。
- 线程生命周期包括七个重要阶段:
- 新建状态(NEW)
- 就绪状态(RUNNABLE)
- 运行状态(RUNNABLE)
- 超时等待状态(TIMED_WAITING)
- 等待状态(WAITING)
- 阻塞状态(BLOCKED)
- 死亡状态(TERMINATED)
线程休眠与终止
线程的 sleep
Thread.sleep()
是 Java 中的一个静态方法,可以让当前线程暂停指定的时间。该方法会将当前线程暂停指定的毫秒数,让其他可用的线程获得执行机会,但是当前线程的锁并不会被释放。因此,在 sleep 期间,其他线程如果请求获得了该线程占有的锁,那么这些线程也无法执行。
Thread.sleep()
方法的常见用法有:
- 模拟耗时操作,例如网络请求等场景。使用
Thread.sleep()
可以模拟网络请求的等待时间,从而测试应用在高并发场景下的表现等。 - 控制线程执行的顺序。在多线程编程中,可能需要确保某个线程先于其他线程执行,使用
Thread.sleep()
方法可以使当前线程暂停一定的时间,等待其他线程执行完毕。
以下是Thread.sleep()
的示例代码:
在这个示例中,main
方法中的 for 循环使用了Thread.sleep()
方法,让当前线程暂停 1 秒钟,从而可以控制输出的每一行之间的时间间隔。通过这个示例,我们可以看到Thread.sleep()
方法让当前线程暂停指定的时间,并在暂停结束后继续执行。
在 Java 中,Thread.sleep()
方法是一种让当前线程进入阻塞状态的方法,只能通过等待指定时间或被其他线程中断才能使当前线程继续执行。如果想要提前结束Thread.sleep()
方法,可以使用Thread.interrupt()
方法中断当前线程的sleep
。当执行Thread.interrupt()
方法后,如果当前线程正在sleep
,则会抛出InterruptedException
异常并清楚中断状态,如果当前线程没有正在sleep
,则线程的中断状态被设置为true
。
以下代码演示了如何在等待 5 秒后结束Thread.sleep()
:
在这个示例中,我们创建了一个Task
线程,在线程中使用Thread.sleep()
方法让其进入阻塞状态。在main
方法中执行Thread.sleep(5000)
等待 5 秒后,通过执行task.interrupt()
中断线程。如果线程没有被中断,那么它会一直在run
方法中运行;如果线程被中断了,它会抛出InterruptedException
异常并处理interrupt
,终止线程的运行。
需要注意的是,在某些情况下,Thread.interrupt()
方法并不能让当前线程立即结束sleep
,因为该线程可能会在某个可能抛出InterruptedException
异常的操作中阻塞。为了确保线程能够在适当的时间内结束阻塞,我们可以在run
方法中添加对线程中断状态的检查,例如使用interrupted()
方法判断是否已经被中断。
如何正确终止一个线程的执行 使用布尔标志终止线程
这种方式适用于线程内部有循环等待或长时间执行却需要随时判断是否要退出的场景。开发者需要在循环内部定期检查退出标记以决定是否要退出循环,实现的方式如下:
java
public class StoppableTask extends Thread {
private boolean stopRequested;
@Override
public void run() {
while (!stopRequested) {
// do something
}
}
public void requestStop() {
stopRequested = true;
}
}
当代码调用requestStop()
方法时,StoppableTask
的线程将在下一次检查到stopRequested
时退出循环。
守护线程
Java 中的守护线程是一种特殊的线程,当所有的非守护线程都结束时,守护线程会自动退出。在 Java 中,GC 线程就是一种典型的守护线程。
创建守护线程的方法很简单,只需要将线程的 setDaemon()方法设置为 true 即可。例如:
java
Thread myThread = new Thread(new MyRunnable());
myThread.setDaemon(true);
myThread.start();
在这个示例中,创建了一个守护线程 myThread,并将其设置为守护线程。当程序中所有的非守护线程结束时,myThread 会自动退出。
需要注意的是,只有在启动守护线程之前将其设置为守护线程才有效。如果在线程已经运行的情况下将其设置为守护线程,是无法将其设置为守护线程的。
另外,守护线程并不是每次运行都会执行完成,因此不能依赖守护线程完成关键任务。
Java 中的守护线程是一种特殊的线程,在所有的非守护线程结束后,守护线程会自动退出。守护线程的作用如下:
- 辅助非守护线程
- 在特定场景下提高性能
定时任务
Java 中提供了 Timer 和 TimerTask 两个类来实现定时任务。以下是使用 Timer 和 TimerTask 实现定时任务的步骤:
- 创建 Timer 对象 11. 创建 TimerTask 对象
- 将 TimerTask 对象和时间间隔传递给 Timer 对象
timer.schedule(task, delay, period);
其中 delay 表示定时任务的延迟时间,period 表示定时任务的时间间隔,单位均为毫秒。
例如,以下代码实现了一个每隔 1 秒输出一次"Hello World!"的定时任务:
java
Timer timer = new Timer();
TimerTask task = new TimerTask() {
@Override
public void run() {
System.out.println("Hello World!");
}
};
timer.schedule(task, 0, 1000);
线程的调度
线程调度
- 线程合并
- 线程优先级
- 线程让位
在 Java 中,使用 Thread 类提供的 join()方法可以合并线程。join()方法会等待线程执行完成之后再继续执行当前线程。
线程优先级可以用来指定线程的执行优先级,优先级高的线程会优先获得 CPU 资源,执行速度也会更快。然而,线程优先级并不是让我们可以依赖的东西,因为它受到底层操作系统调度的影响,不同操作系统调度方式不同,在不同的操作系统上表现也不同。此外,线程优先级也很容易引起线程死锁等问题,因此在线程编程时要慎用。
在 Java 中,可以通过 Thread 类的 setPriority()方法和 getPriority()方法来设置和获取线程的优先级。其中,线程的优先级可以设置为 1~10 之间的一个整数,数字越大表示优先级越高。
代码实现:thread.setPriority(10);
线程让位是指当前正在执行的线程主动放弃 CPU 资源,让其他线程先执行。线程让位的主要作用是调整线程执行顺序,使得高优先级的线程能够更快地执行完任务,从而提高程序的响应性和效率。
Thread.yield(); // 线程让位
线程安全问题
什么是线程安全问题
在 Java 中,线程安全问题指的是多个线程同时操作同一个共享资源(如变量、对象等),导致出现不正确的结果或者抛出异常的问题。多个线程同时修改同一个变量或对象,导致最终结果与预期不符的问题。这种问题通常发生在并发编程中,因为多个线程在执行过程中是无法预测的,它们的执行顺序和执行时间是不确定的,如果不加以控制,就有可能数据安全问题。
线程安全问题在 Java 中通常通过加锁机制来解决。例如,可以使用 synchronized 关键字来保护共享资源的访问。
同步代码块
在 Java 中,可以使用同步代码块来实现线程安全问题。同步代码块将一段代码标记为临界区,同一时间只能有一个线程进入该临界区进行执行,其他线程需要等待当前线程执行完毕后才能进入临界区进行执行。这就保证了共享资源的操作是有序的,避免了线程安全问题的发生。
下图是使用同步代码块实现线程安全问题的示例代码:
在这个示例中,MyThread 类表示一个简单的线程类,其中的 run()方法中,采用同步代码块来实现对 count 变量的安全操作。在 Main 类中,分别创建了两个 MyThread 对象,并启动它们。
同步代码块的执行原理是,每个对象都有一个监视器(也称为锁),进入同步代码块时会尝试获取该对象的监视器,如果获取成功,则可以进入临界区执行,执行完毕后释放监视器,其他线程才有机会获取监视器进入临界区执行。如果获取监视器失败,则需要等待其他线程释放监视器后再次尝试获取。
需要注意的是,在使用同步代码块时,要遵循以下原则:
- 同步代码块应该尽可能简短,只包含对共享资源的操作。
- 同步代码块中的锁对象应该尽可能唯一,避免多余的竞争和等待。
- 同步代码块中的操作应该尽可能快速,避免影响程序的响应性和效率。
同步实例方法
在 Java 中,也可以使用同步实例方法实现线程安全问题。与同步代码块类似,同步实例方法使用Synchronized
关键字来指定一个锁对象,对方法加锁,确保同一时间只有一个线程可以访问该实例方法。请看下图代码。
在这个示例中,MyThread 类中的 run()方法被定义为同步实例方法,在方法前加上了synchronized
关键字。Main 类中创建了两个 MyThread 对象,并启动它们。
同步实例方法的执行原理与同步代码块类似,通过获取当前对象的监视器进行加锁,确保同一时间只有一个线程可以访问该实例方法。需要注意的是,同步实例方法是在实例上进行锁定,而不是在整个类上进行锁定,因此不同的实例可以并发执行同步实例方法,不会相互干扰。
需要注意的是,在使用同步实例方法时,同一对象上的其它同步方法会被阻塞,但非同步方法不会。另外,同步实例方法中锁定的是当前对象,如果有多个对象则不会阻塞对其它对象的访问。
静态方法上添加 synchronized
凡是在静态方法上添加 synchronized,线程执行时会找类锁,记住:一个类不管创建了多少个对象,类只有一个,类锁也只有一把锁,通常静态方法上添加 synchronized 是为了保护静态变量的安全。
死锁 死锁指的是多个线程或者进程在执行过程中,互相持有对方需要的资源而造成的一种互相等待的状态,导致所有线程都无法继续执行下去,从而发生了一种僵局。
造成死锁的原因通常是多个线程按照不同的顺序获取锁,从而造成了循环依赖。当线程 A 持有了锁 1,尝试获取锁 2 时发现被线程 B 持有,线程 B 同时持有锁 2 并且尝试获取锁 1 时发现被线程 A 持有,因此两个线程互相等待对方释放锁,造成了死锁状态。
线程间的通信
线程通信的三个方法:wait()、notify()、notifyAll()
- wait(): 线程执行该方法后,进入等待状态,并且释放对象锁。
- notify(): 唤醒优先级最高的那个等待状态的线程。【优先级相同的,随机选一个】。被唤醒的线程从当初 wait()的位置继续执行。
- notifyAll(): 唤醒所有 wait()的线程
- 需要注意的:
- 以上三个方法在使用时,必须在同步代码块中或同步方法中。
- 调用这三个方法的对象必须是共享的锁对象。
- 这三个方法都是 Object 类的方法。
- wait()和 sleep 的区别?
- 相同点:都会阻塞。
- 不同点:
- wait 是 Object 类的实例方法。sleep 是 Thread 的静态方法。
- wait 只能用在同步代码块或同步方法中。sleep 随意。
- wait 方法执行会释放对象锁。sleep 不会。
- wait 结束时机是 notify 唤醒,或达到指定时间。sleep 结束时机是到达指定时间。
单例模式的线程安全问题
单例模式线程安全问题
- 使用`synchronized`关键字来实现单例模式可以在一定程度上确保线程安全。在多线程环境下,`synchronized`能够保证同步代码块串行执行,从而避免了多个线程同时创建实例的问题。下面是使用`synchronized`实现单例模式的示例代码:
可重入锁
可重入锁解决线程安全问题
- 可重入锁是一种线程同步的机制,也称为重入锁或者递归锁。当一个线程获得了锁之后,在不释放锁的情况下,仍然可以重复进入同步代码块。这种机制可以避免死锁的发生,也提高了代码的灵活性和可读性。
- 在 Java 中,`ReentrantLock`类就是可重入锁的实现类。它提供了与`synchronized`关键字类似的锁机制,但优势在于其更高的灵活性和可扩展性。下面是一个示例代码:
- 在这个示例中,我们创建了一个`ReentrantLock`对象`lock`,然后在两个线程中使用它来锁定同一段代码块。一个线程两次获取锁之后再释放,另一个线程只是获取一次,之后再释放。
- 需要注意的是,在使用`ReentrantLock`时需要注意其与`synchronized`关键字的区别。`ReentrantLock`需要在`try...finally`语句中手动释放锁,否则可能会出现死锁的问题。同时,`ReentrantLock`的性能较差,只有在某些情况下才会比`synchronized`优越,需要仔细评估情况来选择合适的锁机制。
Callable 实现线程
Callable 实现线程
- 使用`Callable`接口创建线程相对于其他方式的区别在于它可以返回一个值或者抛出一个异常,而其他方式都没有这个特性。这使得`Callable`在某些场景下更加灵活和方便。
- 要使用`Callable`接口创建线程,需要实现`Callable`接口并重写`call()`方法。`call()`方法会在`Thread`对象中通过`start()`方法运行。下面是一个简单的示例代码:
- 在这个示例中,我们创建了一个类`CallableDemo`,实现了`Callable<Integer>`接口,并重写了`call()`方法。`call()`方法计算了 1 到 10 的和,并返回结果。在`main`方法中,我们创建了`CallableDemo`对象,并调用`call()`方法,获取到了计算结果。
- 需要注意的是,在实际应用中,一般是利用`Executor`接口来启动`Callable`任务,可以通过线程池来重用线程和减少线程创建和销毁的开销。
- 相对于其他方式,`Callable`接口的主要优点在于可以返回一个值或者抛出异常,这可以方便地获取到线程执行的结果或者处理异常。此外,`Callable`接口还支持泛型,可以避免类型不匹配的问题。但是由于需要手动启动`Thread`对象,相对来说比较繁琐,还需要利用`Executor`接口进行管理,需要在实际应用中进行评估选用。
线程池实现线程
线程池实现线程
在 Java 中,可以使用 JavaSE 中的线程池来实现线程。JavaSE 中提供了Executor
框架,它提供了线程池的实现和管理。
线程池是一种线程的集合,其中的线程可以被复用,从而避免了线程频繁地创建和销毁的开销。线程池可以接受任务并分配给可用的线程进行执行,使得任务可以异步地执行。
图中是一个使用 JavaSE 中线程池的示例代码:
在这个示例中,我们首先创建一个线程池,有 3 个线程。然后循环提交 10 个任务到线程池执行。每个任务都会打印出自己的编号。任务执行完毕后,线程会自动返回线程池中进行下一次任务的执行。
使用线程池主要有以下两个优点:
- 节约线程创建和销毁的开销,提高程序运行效率。
- 可以保证线程的可控性和资源的不会浪费。如果线程池中的线程数量达到线程池的上限,那么线程池会将当前的任务放入队列等待空余线程去执行,从而保证不会同时执行过多的任务导致系统资源的浪费。
需要注意的是,在使用线程池时需要仔细考虑线程池的配置参数,如线程池最大线程数、线程最大等待时间等,以及线程池中的任务的执行时间等因素,来充分发挥线程池的优势。