Java学习之旅第三季-19:线程的创建与启动

19.1 进程与线程

多线程是一个很大的主题,在第三季的学习中,我只关注在Thread的使用及与资源竞争的关键字上,JUC中的API会在第四季中详细介绍。

首先介绍几个基础概念:

  • 程序:编程语言表达的算法,通常是开发人员以符合特定语法的文本来展现,运行时根据编程语言的不同可能需要编译为机器识别的二进制文件或者直接解释执行

  • 进程:程序运行起来后的实例,操作系统会分配所有资源,通常包括:唯一标识符,程序计数器等。它就是操作系统中的一个活动单元。在Windows中,可以在任务管理器中直观看到当前有哪些正在运行的进程

    image-20251027110613890

  • 多任务:操作系统一次执行多个任务(进程)的能力,现代操作系统都是具体多任务能能力的;在单 CPU 机器上,严格意义上讲多任务处理是不可能实现的,因为一个 CPU 在同一时间只能为一个进程执行指令。在这种情况下,操作系统通过将单个 CPU 的时间分配给所有正在运行的进程,并在进程之间快速切换,从而实现多任务处理,给人的印象是所有进程都在同时运行。CPU 在进程之间切换的过程称为上下文切换。在上下文切换中,正在运行的进程会被停止,其状态会被保存,即将获得 CPU 的进程的状态会被恢复,然后运行新进程。在将 CPU 分配给另一个进程之前,必须保存正在运行的进程的状态,这样当该进程再次获得 CPU 时,可以从它停止的地方继续执行。通常,进程的状态包括程序计数器、进程使用的寄存器值以及以后恢复进程所需的任何其他信息。操作系统将进程状态存储在一个称为进程控制块或切换帧的数据结构中。要注意的是上下文切换是一项相当昂贵的任务。

    多任务处理有两种类型:协作式和抢占式。在协作式多任务处理中,正在运行的进程自行决定何时释放 CPU,以便其他进程能够使用 CPU。在抢占式多任务处理中,操作系统为每个进程分配一个时间片。一旦进程用完其时间片,就会被抢占,操作系统将 CPU 分配给另一个进程。在协作式多任务处理中,一个进程可能会长时间独占 CPU,其他进程可能没有机会运行。而在抢占式多任务处理中,操作系统确保所有进程都能获得 CPU 时间

  • 多处理能力:指的是计算机能够同时使用多个处理器的特性

  • 并行处理:系统同时在多个处理器上执行一个任务的能力,此时任务必须被分解为子任务,以便这些子任务能够同时在多个处理器上执行

  • 线程:进程中的一个执行单元,维护自身的程序计数器和栈,线程共享进程的地址空间和资源,线程由CPU分别调度,有可能在不同的CPU上执行

进程与线程的区别与关系

  • 每个进程至少有一个线程,一个进程也可以创建多个线程;比如JVM运行起来就是一个进程,该进程至少有一个主线程,main方法就在主线程中运行

    进程与线程1

  • 每个进程都有独立的代码和数据空间,切换开销大

  • 一个进程内的所有线程共享所有资源,包括地址空间;它们也可以轻松地相互进行通信,因为它们都在同一个进程中运行,并且共享相同的内存。进程内的每个线程都独立于同一进程内的其他线程运行。线程切换开销小,不过由于会共享数据,这会造成高并发下的数据不一致问题。

  • 线程一定属于某一个进程;由线程创建并启动的另一个线程通常称之为子线程

在所有现代操作系统中,调度到 CPU 上执行的是线程,而非进程。因此,CPU 上的上下文切换发生在线程之间。线程之间的上下文切换比进程之间的上下文切换成本更低。由于线程间通信便捷、进程内线程间资源共享容易以及上下文切换成本较低,所以通常更倾向于将一个程序拆分为多个线程,而非多个进程。有时,线程也被称为轻量级进程。比如下图所示的六条指令的程序也可以在一个进程内拆分为两个线程。在多处理器机器上,一个进程的多个线程可能会被调度到不同的处理器上,从而实现程序的真正并发执行。

进程与线程

19.2 创建与启动线程

在Java中,Thread 类表示线程,该类实现了Runnable接口,它们都属于java.lang包。

Runnable接口是一个函数式接口,声明的唯一抽象方法 void run();

最简单的线程就是Thread的一个实例:

Thread t= new Thread();

想要启动该线程,就需要调用其start方法:

t.start();

一旦线程启动,则会开始调度以获得CPU时间片,当获取到CPU时间片后就会执行器run方法,run方法执行完成后,线程即结束。

不过无参构造方法并没有给线程传入任何任务,执行了也不会有什么效果,要想给线程提供任务,有两种常见的实现方式:

  • 继承Thread类并重写 run 方法
  • 实现Runnable接口,将其实例传给Thread的带参构造方法

1、实现方式1 :继承Thread类并重写 run 方法

public class MyThread1 extends Thread {
    @Override
    public void run() {
        System.out.println("MyThread1 run");
    }
}

上面的run方法中就是这个新创建线程的任务,在main方法中实例化该线程并调用其start方法即可启动该线程:

Thread myThread1=new MyThread1();
myThread1.start();

虽然在控制台看到输出了"MyThread1 run",我们将代码改造下,以便看到更明显的效果,这有助于理解线程的执行特点:

public class MyThread1 extends Thread {
    @Override
    public void run() {
        String currentThreadName = Thread.currentThread().getName();
        for (int i = 0; i < 10; i++) {
            System.out.println(currentThreadName + ":" + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

重构后的MyThread1中,run方法中的逻辑如下:

第4行使用Thread的静态方法获取到当前线程,然后使用getName方法获取该线程的名称。

接着使用for语句循环10次,每次将循环变量输出,且使用Thread的静态方法sleep暂行运行500毫秒(半秒)。

同时main方法也使用类似的方式重构如下:

static void main() {
    Thread myThread1=new MyThread1();
    myThread1.start();

    String currentThreadName = Thread.currentThread().getName();
    for (int i = 0; i < 10; i++) {
        System.out.println(currentThreadName + ":" + i);
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

主方法首先启动线程MyThread1,然后主线程会继续执行,同样获取主线程的名称,然后每500毫秒执行输出,运行之后的效果如下:

Thread-0:0
main:0
main:1
Thread-0:1
main:2
Thread-0:2
Thread-0:3
main:3
.......

通过输出结果,可以看到主线程与子线程的输出是交替的,当然具体的输出结果跟CPU调度有关,并不是一成不变。

2、实现Runnable接口,将其实例传给Thread的带参构造方法

还是上面的逻辑,这一次的写法不同:

public class MyTarget implements Runnable {
    @Override
    public void run() {
        String currentThreadName = Thread.currentThread().getName();
        for (int i = 0; i < 10; i++) {
            System.out.println(currentThreadName + ":" + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

测试代码稍有不同,但执行效果是一样的:

Thread myThread=new Thread(new MyTarget());
myThread.start();

两种实现方式的不同在于:

  • 继承 Thread 类:Thread 是一个类,通过继承它并重写 run() 方法来定义线程任务。由于 Java 是单继承(一个类只能继承一个父类),如果一个类已经继承了其他类,就无法再继承 Thread。
  • 实现 Runnable 接口:Runnable 是一个函数式接口(仅含 run() 方法),类通过实现它来定义任务,同时可以继承其他类。这种方式更符合 “面向接口编程” 的设计思想。

另外,从线程与任务的耦合性来说,它们也有差异:

  • 继承 Thread 类:线程对象(Thread 子类实例)与任务(run() 方法)是强耦合的。一个 Thread 子类实例只能对应一个任务,无法重复利用线程对象执行不同任务。
  • 实现 Runnable 接口:任务(Runnable 实现类)与线程(Thread 对象)是解耦的。一个 Runnable 实例可以被多个 Thread 对象共享,实现 “多线程执行同一个任务”。

除了上述两种方式,Java 还提供了 Callable 接口(配合 Future)实现多线程,它支持返回值和抛出受检异常,功能比 Runnable更强大,是现代并发编程的常用选择(常与线程池结合)。这会在第四季中详细介绍。

19.3 Thread类的构造方法

除了前面见到的两种构造方法外,Thread类一共提供了9种构造方法:

Thread()
Thread(Runnable task)
Thread(ThreadGroup group, Runnable task)
Thread(String name)
Thread(ThreadGroup group, String name)
Thread(Runnable task, String name)
Thread(ThreadGroup group, Runnable task, String name)
Thread(ThreadGroup group, Runnable task, String name, long stackSize)
Thread(ThreadGroup group, Runnable task, String name, long stackSize, boolean inheritInheritableThreadLocals)

我就不一一演示了,主要介绍其中使用到的参数:

  • name:线程名,可以在实例化线程时为线程指定一个名称,当然没有指定该属性的话,内部也有默认线程名

  • Runnable:这个是表示线程任务的函数式接口,使用时可以直接实现它并重写run‘方法以提供自己的逻辑;也可以使用Lambda表达式或方法引用。比如:

    new Thread(() -> {
        String currentThreadName = Thread.currentThread().getName();
        for (int i = 0; i < 10; i++) {
            System.out.println(currentThreadName + ":" + i);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();
    
  • stackSize:默认是0,指定线程的栈大小,若为 0 则表示应忽略此参数

  • inheritInheritableThreadLocals:如果为true,则从创建线程处继承局部变量的初始值,否则不会继承任何初始值

  • ThreadGroup:线程组,这是一个用于管理一组线程的类,它可以将多个线程归类到一个组中,便于统一管理和控制。其主要作用是对线程进行批量操作,比如统一设置优先级、统一中断等,同时也方便进行线程的组织和管理。

19.4 ThreadGroup类

ThreadGroup 类也是 java.lang 包下的一个类。每个线程都属于一个线程组,如果创建线程时未指定线程组,该线程会默认属于其父线程所在的线程组(通常是 main 线程组)。

线程组之间可以形成父子关系,一个线程组可以包含多个子线程组和线程,构成一个树形结构。根线程组是 system 线程组,main 线程组是其直接子组。

构造方法:

  • ThreadGroup(String name):创建一个指定名称的新线程组,父线程组是当前线程所在的线程组
  • ThreadGroup(ThreadGroup parent, String name):指定父线程组和名称,创建一个新的线程组

常用方法:

  • int activeCount():返回当前线程组中活跃线程的估计数量(包括子线程组中的线程)

  • int activeGroupCount():返回当前线程组中活跃子线程组的估计数量

  • void list():打印线程组的信息(包括线程和子线程组)到标准输出,便于调试

  • ThreadGroup getParent():返回当前线程组的父线程组

  • boolean parentOf(ThreadGroup g):判断当前线程组是否是指定线程组的父组(包括间接父组)

下面的例子中,使用线程组管理多个线程:

ThreadGroup group = new ThreadGroup("MyThreadGroup");
Thread thread1 = new Thread(group, () -> {
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}, "thread1");
Thread thread2 = new Thread(group, () -> {
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}, "thread2");
thread1.start();
thread2.start();

group.list();
System.out.println(group.activeGroupCount());
System.out.println(group.activeCount());

不过目前 Java 开发中,线程组的功能逐渐被线程池(ExecutorService)和并发工具类替代,线程池更适合批量管理线程的生命周期和任务调度。

19.5 小结

本小节介绍了Java多线程编程的基础知识,重点讲解了进程与线程的区别、线程的创建与启动方式,以及Thread类的构造方法。首先区分了程序、进程、线程等核心概念,随后详细讲解了两种创建线程的方法:继承Thread类和实现Runnable接口,并通过代码示例演示了线程的执行特点。最后简要提及Thread类的多种构造方法,为后续学习线程池和高级并发API打下基础。最后强调了解耦任务与线程的重要性,并指出实现Runnable接口的方式更符合面向接口编程思想,同时暗示了后续将介绍更强大的Callable接口。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值