一篇文章拿下多线程

291 阅读18分钟

多线程

一。认识线程(Thread)

1.1) 线程是什么

线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,多个这样的控制流(线程)在进程中相互配合、并发执行,共同完成复杂的任务。每个线程都有自己的程序计数器、寄存器组和栈等资源,用于记录线程的执行进度和状态。

例子:想象一下你在家里准备一顿晚餐。你可以同时做几件事情,比如一边切菜,一边煮汤,还有一边烤面包。每个活动就像一个线程,在同一个厨房(进程)中并行工作,提高了效率。厨房中的各种厨具、食材等资源就如同进程中的共享资源,各个线程(活动)可以按需取用,协同完成做晚餐这个 “进程” 任务。

1.2) 为啥要有线程

线程的存在是为了提高程序的响应速度和资源利用率。多线程可以让程序的不同部分同时运行,从而提升整体性能。特别是在现代多核处理器环境下,多线程可以充分利用硬件资源,实现真正的并行计算。例如在电商系统中,订单处理、库存更新、物流信息推送等多个任务可以分别由不同线程同时处理。当大量订单涌入时,多个线程并行工作,能快速响应顾客需求,缩短订单处理时间,提升用户体验。

例子:如果你正在看视频的同时下载文件,这两个任务可以分别由两个线程处理。这样,你既可以继续观看视频,又不会因为下载而影响播放体验。这就好比你有两只手,一只手拿着手机看视频娱乐,另一只手操作电脑下载资料,两手同时做事互不干扰,提高了时间利用效率。

1.3) 进程和线程的区别
  • 进程
    • 定义与特点:进程是一个独立的运行环境,拥有独立的地址空间。这意味着每个进程在内存中都有自己独立的区域用于存储数据和代码等信息,不同进程之间的数据是相互隔离的。例如,当你同时打开浏览器和文本编辑器两个不同的软件,它们就是两个独立的进程,浏览器进程中的内存数据(如浏览历史、缓存等)与文本编辑器进程中的内容(正在编辑的文档等)是完全分开的。
    • 切换开销与隔离性:由于进程有独立的地址空间,在进行进程间切换时,需要进行一系列复杂的操作,像保存当前进程的执行上下文(包括寄存器的值、程序计数器等信息),然后加载下一个进程的相关信息,所以进程之间的切换开销较大。不过这种独立的地址空间也带来了很好的隔离性,一个进程崩溃了,通常不会影响到其他进程的正常运行,就好比一个房子(进程)倒塌了,相邻的房子(其他进程)依然可以保持完好无损。
  • 线程
    • 定义与特点:线程是进程内的一个执行单元,它共享进程的地址空间。也就是说,同一个进程中的多个线程可以直接访问进程中的共享数据和资源,这使得线程之间的通信变得相对方便,不需要像进程间通信那样经过复杂的机制(如管道、消息队列等)。例如在一个 Web 服务器进程中,多个线程可以同时处理不同客户端的请求,它们都能访问服务器进程中存放的网页资源等数据。
    • 切换开销与崩溃影响:线程之间的切换开销较小,因为它们共享进程的地址空间,切换时不需要像进程切换那样进行大量的上下文保存和加载工作,只需要切换线程相关的执行上下文即可。然而,正是由于线程共享地址空间,它们之间的关联性较强,一个线程如果出现了严重错误(比如访问了非法内存地址等)导致崩溃,很可能会影响到整个进程,进而使得进程中的其他线程也无法正常执行,就如同一个房间(线程)里发生了严重问题(如着火了),整个房子(进程)里的其他房间(其他线程)都会受到牵连。

例子:进程就像是一个完整的家庭,每个房间(线程)可以在同一屋檐下做不同的事情。虽然大家都在同一个家里,但每个房间里的活动相对独立,不过如果房子(进程)倒塌了,所有房间都会受到影响。

1.4) Java 的线程和操作系统线程的关系

在 Java 中,线程是通过 java.lang.Thread 类来表示的。当你创建一个 Java 线程时,JVM(Java 虚拟机)会与操作系统合作,为该线程分配一个操作系统级别的线程。因此,Java 线程实际上是操作系统线程的一个抽象,依赖于操作系统的线程调度机制。不同操作系统的线程调度策略有所差异,例如 Windows 系统采用基于优先级的抢占式多任务调度,Linux 系统有多种调度算法如 CFS(完全公平调度算法)等,这些都会影响 Java 线程的实际执行效果。JVM 在与操作系统协作分配线程时,需要适配不同操作系统的特性,尽量保证 Java 线程在不同平台上的一致性表现。

例子:Java 线程就像是一个管家,负责协调家里的各种事务(操作系统线程)。管家会根据主人的指示(Java 代码)安排具体的工作,确保一切顺利进行。而不同的房子(操作系统)有不同的布局和管理规则,管家(Java 线程)需要灵活应变,遵循房子的规则(操作系统线程调度策略)来把家里打理得井井有条。


二。创建线程

方法 1: 继承 Thread 类
class MyThread extends Thread {
    public void run() {
        System.out.println("Hello from a thread!");
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start();
        // 继承Thread类的方式较为直观简单,适用于简单的线程创建需求,但由于Java单继承的限制,使用场景相对有限。例如,当一个类已经继承了其他父类,就无法再直接继承Thread类来创建线程。
    }
}
方法 2: 实现 Runnable 接口
class MyRunnable implements Runnable {
    public void run() {
        System.out.println("Hello from a runnable!");
    }
}
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable());
        t.start();
        // 实现Runnable接口的方式避免了Java单继承的限制,更加灵活。适用于需要在已有类层次结构基础上添加线程功能的场景,例如一个已经继承了业务逻辑父类的类,只需实现Runnable接口就能轻松实现多线程功能。
    }
}
方法 3: 匿名内部类创建 Thread 子类对象
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread() {
            public void run() {
                System.out.println("Hello from an anonymous thread!");
            }
        };
        t.start();
        // 匿名内部类创建方式适合临时、简单的线程创建需求,代码简洁,无需额外定义类。但缺点是代码可读性相对较差,当逻辑复杂时不利于维护,常用于一些简单的测试或快速原型开发场景。
    }
}
方法 4: 匿名内部类创建 Runnable 子类对象
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            public void run() {
                System.out.println("Hello from an anonymous runn�able!");
            }
        });
        t.start();
        // 这种方式结合了匿名内部类和实现Runnable接口的优点,既简洁又能避免单继承问题,常用于一些临时需要创建简单线程任务的情况,在快速开发小功能模块时比较方便。
    }
}
方法 5: 使用 Lambda 表达式创建 Runnable 子类对象
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> System.out.println("Hello from a lambda thread!"));
        t.start();
        // Lambda表达式创建线程是Java 8之后引入的简洁方式,尤其适用于线程任务逻辑简单,只需执行少量语句的场景,让代码更加紧凑、易读,能大大提升开发效率,是现代Java多线程开发中常用的快捷方式。
    }
}

三. Thread 类及其方法

3.1) Thread 的常见构造方法
  • Thread():创建一个新的空线程。例如:
Thread emptyThread = new Thread();
emptyThread.start();
// 这里创建了一个基本的空线程,通常后续需要重写其run方法来定义具体任务,适用于先创建线程对象,后续再根据具体情况定制任务的场景。
  • Thread(Runnable target):创建一个新的线程,并将 target 作为其任务。示例:
class PrintTask implements Runnable {
    public void run() {
        System.out.println("执行打印任务");
    }
}
Thread printThread = new Thread(new PrintTask());
printThread.start();
// 这种构造方式方便将一个实现了Runnable接口的任务对象赋予线程,使得线程的任务定义和线程创建分离,提高了代码的模块化程度,常用于有明确任务模块需要在线程中执行的情况。
  • Thread(String name):创建一个新的线程,并指定其名称。如:
Thread namedThread = new Thread("日志记录线程");
namedThread.start();
// 给线程指定名称有助于在调试多线程程序时快速识别不同线程,方便排查问题,特别是当系统中有多个线程协同工作时,清晰的名称能让开发者一目了然。
  • Thread(Runnable target, String name):创建一个新的线程,指定其任务和名称。演示:
class NetworkTask implements Runnable {
    public void run() {
        System.out.println("执行网络请求任务");
    }
}
Thread networkThread = new Thread(new NetworkTask(), "网络请求线程");
networkThread.start();
// 这种方式结合了前面两种构造方法的优点,既指定了具体的任务,又给线程赋予了易于识别的名称,在实际开发中广泛应用于各种复杂的多线程任务场景。
3.2) Thread 的几个常见属性
  • getName() 和 setName(String name):获取或设置线程的名称。示例:
Thread sampleThread = new Thread();
sampleThread.setName("测试线程");
System.out.println(sampleThread.getName());
// 先创建一个线程,然后设置名称为“测试线程”,最后通过getName方法获取并打印,展示了如何操作线程名称,方便线程管理与调试。
  • getPriority() 和 setPriority(int priority):获取或设置线程的优先级。Java 线程优先级取值范围是 1 - 10,默认优先级为 5。例如:
Thread highPriorityThread = new Thread();
highPriorityThread.setPriority(8);
System.out.println(highPriorityThread.getPriority());
// 这里创建一个线程并将其优先级设置为 8,高于默认值,再获取优先级打印,说明线程优先级的设置与获取操作,不同优先级的线程在操作系统调度时会有不同的机会获取 CPU 时间片。
  • isDaemon() 和 setDaemon(boolean on):判断或设置线程是否为守护线程。守护线程是一种特殊的线程,当进程中所有非守护线程结束时,守护线程会自动终止。示例:
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
System.out.println(daemonThread.isDaemon());
// 创建一个线程并设置为守护线程,然后判断并打印其是否为守护线程状态,守护线程常用于执行一些后台辅助任务,如垃圾回收线程就是守护线程,不会阻止进程正常退出。
3.3) 获取当前线程引用

使用 Thread.currentThread() 可以获取当前正在执行的线程对象。这在需要操作当前线程时非常有用。例如:

public class CurrentThreadExample {
    public static void main(String[] args) {
        Thread current = Thread.currentThread();
        System.out.println(current.getName());
        // 在main方法中获取当前线程引用,通常main线程名称就是“main”,通过这个示例展示如何在代码中随时获取当前执行线程,以便进行相关操作,比如获取线程属性、设置线程状态等。
    }
}
3.4) 休眠当前线程

Thread.sleep(long millis) 可以让当前线程暂停执行一段时间,单位为毫秒。这对于模拟延迟、等待其他线程完成等场景非常有用。例如:

public class SleepExample {
    public static void main(String[] args) {
        try {
            System.out.println("开始休眠");
            Thread.sleep(3000);
            System.out.println("休眠结束");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 主线程在执行到Thread.sleep(3000)时会暂停 3 秒,期间不占用 CPU 资源,模拟一些需要等待的场景,如定时任务的间隔等待、等待其他资源就绪等,注意要处理InterruptedException异常。
    }
}

四。线程的状态

屏幕截图 2024-12-23 161657.png

4.1) 线程的所有状态

线程在其生命周期中有以下几种状态:

  • NEW:线程刚刚被创建,但还没有启动。示例代码:
Thread newThread = new Thread(() -> System.out.println("新线程任务"));
System.out.println(newThread.getState());
// 这里创建了一个线程但尚未启动,通过getState方法获取其状态,此时状态应为NEW,展示了线程初始创建后的状态。
  • RUNNABLE:线程正在执行或准备好执行,等待 CPU 时间片。比如:
Thread runnableThread = new Thread(() -> {
    while (true) {
        // 简单的循环任务,模拟线程持续运行
    }
});
runnableThread.start();
System.out.println(runnableThread.getState());
// 创建并启动一个线程执行简单循环任务,由于线程启动后可能处于等待 CPU 分配时间片的状态,所以获取状态可能为RUNNABLE,说明线程运行过程中的一种常见状态。
  • BLOCKED:线程被阻塞,等待获取锁。以下是示例:
class SharedResource {
    public synchronized void access() {
        // 模拟共享资源的访问方法
    }
}
SharedResource resource = new SharedResource();
Thread blockedThread1 = new Thread(() -> {
    resource.access();
});
Thread blockedThread2 = new Thread(() -> {
    resource.access();
});
blockedThread1.start();
blockedThread2.start();
System.out.println(blockedThread2.getState());
// 两个线程同时尝试获取同一个共享资源的锁,当一个线程获取到锁进入同步方法后,另一个线程就会进入BLOCKED状态,通过此示例展示线程阻塞等待锁的情况。
  • WAITING:线程无限期等待另一个线程执行特定操作。示例:
class WaitNotifyExample {
    static Object lock = new Object();
    static Thread waitingThread;
    public static void main(String[] args) {
        waitingThread = new Thread(() -> {
            synchronized (lock) {
                try {
                    System.out.println("线程开始等待");
                    lock.wait();
                    System.out.println("线程等待结束");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        waitingThread.start();
    }
}
// 一个线程在获取到锁后调用wait方法,进入无限期等待状态,直到其他线程调用同一对象的notify或notifyAll方法唤醒它,展示了WAITING状态的触发与解除。
  • TIMED_WAITING:线程等待指定的时间后继续执行。如:
public class TimedWaitExample {
    public static void main(String[] args) {
        Thread timedWaitingThread = new Thread(() -> {
            try {
                System.out.println("开始定时等待");
                Thread.sleep(5000);
                System.out.println("定时等待结束");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        timedWaitingThread.start();
        System.out.println(timedWaitingThread.getState());
        // 线程执行到Thread.sleep(5000)时进入TIMED_WAITING状态,等待 5 秒后继续执行,说明这种状态在定时任务场景中的出现情况。
    }
}
  • TERMINATED:线程已经结束执行。例如:
Thread terminatedThread = new Thread(() -> System.out.println("线程执行完毕"));
terminatedThread.start();
try {
    terminatedThread.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println(terminatedThread.getState());
// 先启动一个线程执行简单任务,然后通过join方法等待该线程执行结束,此时获取线程状态应为TERMINATED,展示了线程正常结束后的状态。
4.2) 线程状态和状态转移的意义

线程的状态转移反映了线程在其生命周期中的不同阶段。了解这些状态有助于我们更好地调试和优化多线程程序。例如,当一个线程处于 BLOCKED 状态时,可能是因为它在等待获取某个锁,这可能是性能瓶颈的标志,此时我们可以考虑优化锁的使用策略,如采用更细粒度的锁、减少锁的持有时间等。又如,当发现大量线程处于 WAITING 或 TIMED_WAITING 状态,且等待时间过长,可能意味着

4.3)观察线程的状态和转移

在实际的多线程编程中,观察线程的状态以及它们之间的转移是至关重要的。这能帮助我们深入理解程序的执行流程,及时发现潜在的问题。

示例1: 我们可以利用一些调试工具,如Java自带的 jconsole 或者 VisualVM 。以 jconsole 为例,在启动含有多线程的Java程序后,通过命令行启动 jconsole 并连接到对应的Java进程。在其界面中,我们能够清晰地看到各个线程的当前状态,包括是处于 RUNNABLE 、 BLOCKED 还是其他状态,并且可以实时观察随着程序运行,线程状态的动态变化。比如,当一个多线程的Web服务器在处理大量并发请求时,通过 jconsole 查看线程状态,我们可以发现某些线程在等待数据库连接时变为 BLOCKED 状态,当获取到连接后又转为 RUNNABLE 状态,这种直观的观察有助于我们优化服务器的性能,调整线程池的配置等。

示例2: 在代码层面,我们也可以通过定时输出线程状态来进行简单的观察。例如,创建一个辅助类,每隔一定时间(如1秒)遍历系统中的线程,获取它们的状态并打印出来。如下所示:

import java.util.concurrent.TimeUnit;
public class ThreadStateObserver {
    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                Thread[] threads = new Thread[Thread.activeCount()];
                Thread.enumerate(threads);
                for (Thread thread : threads) {
                    if (thread!= null) {
                        System.out.println(thread.getName() + " : " + thread.getState());
                    }
                }
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

这段代码启动一个新线程,它会持续每秒遍历并打印当前系统中所有线程的名称和状态,让我们在不借助复杂工具的情况下,也能初步了解线程状态的变化情况,对于一些简单的多线程程序调试非常实用。


五:多线程带来的的风险-线程安全(重点)

5.1线程安全的概念

线程安全指多线程环境下程序能正确运行,多个线程同时访问共享资源时,不会因并发操作致程序出错。如银行账户系统,多线程同时存款、取款,若代码线程安全,账户余额始终正确,不会有重复扣钱、金额计算错误等问题,反之则可能致用户资金受损、系统数据出错。

5.2线程不安全的原因

导致线程不安全主要有以下几个原因:

首先,多个线程对共享变量的并发读写是常见问题根源。多个线程同时读写同一共享变量时,因指令执行顺序、CPU调度等因素,可能错误修改数据。如两个线程对全局计数器自增,正常应加2,因并发问题可能只加1。

其次,线程切换时机也可能引发问题。线程调度由操作系统决定,关键操作执行一半时线程切换,另一线程执行相同操作,易出现数据不一致。例如链表插入操作,一线程插入节点前半部分,没完成后半部分指针更新就被切换,另一线程遍历或修改链表,可能导致链表结构错乱。

再者,编译器优化和CPU指令重排序在多线程环境下也可能引发问题。编译器为提高执行效率优化代码、调整指令执行顺序,单线程没问题,但多线程中可能破坏依赖关系,出现不可预期结果。

5.3线程的几大特性
5.3.1:原子性

原子性指操作或系列操作不可分割,要么全执行成功,要么全不执行,不会中断。多线程下保证原子性很重要,如数据库事务操作,含多个SQL语句,像插入记录、更新表数据等,作为整体须有原子性,要么全成功提交,要么全回滚,否则致数据不一致。Java中,java.util.concurrent.atomic包下的原子类如AtomicInteger,其自增、自减操作具原子性,能保证多线程对整数变量操作的正确性。

5.3.2:可见性

可见性即一个线程对共享变量的修改,其他线程能立即看到。多线程编程里,各线程有自己的工作内存,共享变量可能被复制过去,若无线合适同步机制,一个线程修改共享变量值后,其他线程可能察觉不到,仍用旧值。比如主线程中一个线程修改标志位,通知其他线程停止工作,却因可见性问题,其他线程可能一直在循环,没发现标志位已改,致程序无法正确结束。Java的volatile关键字可保证变量可见性,变量被声明为volatile时,其修改会立即刷新到主内存,其他线程读取时也会直接从主内存读最新值。

5.3.3:指令重排序

指令重排序是编译器和CPU为提升程序性能的优化手段。单线程下,因程序执行结果与指令逻辑依赖关系有关,与执行顺序无关,所以该优化不影响正确性。但多线程下可能有问题,如初始化对象时,编译器可能先分配内存空间,后初始化成员变量。单线程里后续使用对象肯定在初始化完成后,没问题;多线程中,若一线程见对象已分配内存就以为初始化完直接使用,可能出错,因成员变量可能未初始化。Java里, synchronized关键字和volatile关键字的部分功能等同步机制,可限制指令重排序,确保多线程环境下程序的正确性。