【JAVASE | 第十六篇】多线程

前言

学习多线程时,最容易把知识点背成清单:创建线程有几种方式、锁有几种写法、线程池有几个参数。真正写代码时,更重要的是把它们串起来:线程负责执行任务,多个线程操作共享数据时会出现安全问题,任务多了以后再交给线程池统一管理。

一、创建线程的三种方式

1. 继承 Thread

第一种方式是定义一个类继承 Thread,然后重写 run 方法。

java 复制代码
class Mytask extends Thread {
    @Override
    public void run() {
        int i = 0;
        while (i < 5) {
            i++;
            System.out.println("hello");
        }
    }
}

public class ThreadDemo1 {
    public static void main(String[] args) {
        Thread td = new Mytask();
        td.start();
    }
}

这里要注意,启动线程调用的是 start 方法。run 方法里写的是线程要执行的任务,但直接调用 run 只是普通方法调用,不会真正开启新线程。

这种方式写起来直接,但任务类已经继承了 Thread,如果后面还想继承别的类就不方便。

2. 实现 Runnable

第二种方式是把任务单独定义出来,再交给线程对象执行。

java 复制代码
Runnable r = () -> {
    for (int i = 0; i < 5; i++) {
        System.out.println("hello");
    }
};

Thread n = new Thread(r);
n.start();

Runnable 更像是"任务",Thread 更像是"执行任务的人"。把任务和线程对象拆开以后,代码会更灵活,也更适合配合线程池使用。

3. 实现 Callable,配合 FutureTask 获取返回值

前两种方式的共同点是:任务执行完没有返回值。如果线程执行完后还要把结果拿回来,可以使用 Callable。

java 复制代码
Callable<String> task = new mythread(100);
FutureTask<String> futureTask = new FutureTask<>(task);

Thread thread = new Thread(futureTask);
thread.start();

System.out.println(futureTask.get());

你的 mythread 类中,call 方法负责计算 0 到 n 之间的累加结果:

java 复制代码
class mythread implements Callable<String> {
    private int n;

    public mythread(int n) {
        this.n = n;
    }

    @Override
    public String call() {
        int sum = 0;
        for (int i = 0; i < n; i++) {
            sum += i;
        }
        return "结果是" + sum;
    }
}

这套写法的关键是 FutureTask。它一边包装 Callable 任务,一边保存任务执行后的结果。调用 get 时,如果线程还没执行完,当前线程会等待;如果已经执行完,就可以拿到 call 的返回值。

选择创建方式时可以这样判断:只做简单演示,可以继承 Thread;没有返回值的任务,优先 Runnable;需要返回值,使用 Callable 配合 FutureTask。

二、常用线程 API

三个常见 API:线程名称、休眠和插队。

线程名称可以通过构造方法传入:

java 复制代码
class Mytask extends Thread {
    public Mytask(String name) {
        super(name);
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + ":hello");
    }
}

Thread.currentThread 可以获取当前正在执行这段代码的线程。谁执行这行代码,拿到的就是谁。

sleep 用来让当前线程暂停一段时间:

java 复制代码
for (int i = 0; i < 10; i++) {
    System.out.println(i);
    Thread.sleep(1000);
}

join 表示让一个线程先执行完。你的 ThreadjoinDemo3 里,主线程循环到 j 等于 1 时调用 join,此时主线程会等待 td 执行结束,再继续往下走。

java 复制代码
if (j == 1) {
    td.join();
}

这些 API 本身不复杂,但它们能帮助观察线程执行顺序。尤其是打印当前线程名称,在调试多线程代码时很有用。

三、线程安全问题:两个人同时取钱

线程安全问题通常不是"代码一定报错",而是结果可能不对。

java 复制代码
public void Money(double money) {
    String name = Thread.currentThread().getName();

    if (this.money >= money) {
        System.out.println(name + "取钱成功" + money);
        this.money -= money;
        System.out.println("还剩" + this.money);
    } else {
        System.out.println("余额不足");
    }
}

主程序里创建了同一个账户对象,然后让小明和小红两个线程同时取钱:

java 复制代码
Account ac = new Account("123455", 100000);
new Drawthread("小明", ac).start();
new Drawthread("小红", ac).start();

问题就出在"同一个账户对象"上。两个线程共享同一份 money 数据,可能同时判断余额足够,然后都进入取钱逻辑。这样就会出现余额被重复操作的问题。

判断一段代码有没有线程安全风险,可以抓三个条件:是否有多个线程,是否访问共享数据,是否修改了共享数据。这三个条件同时满足,就要考虑加锁。

四、解决方式一:同步代码块

同步代码块可以只锁住真正需要保护的代码。

java 复制代码
public void Money(double money) {
    String name = Thread.currentThread().getName();

    synchronized (this) {
        if (this.money >= money) {
            System.out.println(name + "取钱成功" + money);
            this.money -= money;
            System.out.println("还剩" + this.money);
        } else {
            System.out.println("余额不足");
        }
    }
}

这里锁对象是 this,也就是当前 Account 对象。因为小明和小红使用的是同一个账户对象,所以他们竞争的是同一把锁。

当一个线程进入同步代码块后,另一个线程必须等待。这样余额判断和扣钱操作就能作为一个整体执行,不会出现两个线程同时通过判断的情况。

同步代码块的优点是锁范围清楚,只保护关键代码;缺点是写的时候要自己判断哪些代码必须放进锁里。

五、解决方式二:同步方法

如果整个方法都需要被保护,可以直接在方法上加 synchronized。

java 复制代码
public synchronized void Money(double money) {
    String name = Thread.currentThread().getName();

    if (this.money >= money) {
        System.out.println(name + "取钱成功" + money);
        this.money -= money;
        System.out.println("还剩" + this.money);
    } else {
        System.out.println(name + "取钱失败,余额不足");
    }
}

普通成员方法加同步,本质上锁的还是当前对象。和上面的同步代码块相比,同步方法写法更简洁,但锁的范围是整个方法。

所以选择时可以看范围:只想保护几行核心代码,用同步代码块;整个方法都是临界区,用同步方法更直接。

六、解决方式三:显式锁 ReentrantLock

java 复制代码
private final Lock lk = new ReentrantLock();

public void Money(double money) {
    String name = Thread.currentThread().getName();

    lk.lock();
    try {
        if (this.money >= money) {
            System.out.println(name + "取钱成功" + money);
            this.money -= money;
            System.out.println("还剩" + this.money);
        } else {
            System.out.println(name + "取钱失败,余额不足");
        }
    } finally {
        lk.unlock();
    }
}

显式锁的特点是加锁和释放锁都由程序员控制。这里最重要的写法是 finally 中释放锁。因为取钱逻辑中一旦出现异常,如果没有释放锁,其他线程可能一直拿不到锁。

final 修饰的是 lk 这个引用,意思是这个引用不能再指向别的锁对象。它不等于"对象内容永远不可变"。在这里加 final 的作用是保证同一个 Account 对象一直使用同一把锁。

七、线程池:让线程被复用

前面的写法都是 new Thread 后直接 start。任务少的时候可以这样写,但任务多了以后,频繁创建和销毁线程会有额外开销。线程池的作用就是提前管理一批线程,让任务提交后复用这些线程执行。

使用 ThreadPoolExecutor 创建线程池:

java 复制代码
ExecutorService pool = new ThreadPoolExecutor(
        3,
        5,
        10,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(3),
        Executors.defaultThreadFactory(),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

这几个参数可以按执行流程理解:

  • 核心线程数是 3,线程池会优先使用这 3 个线程处理任务。
  • 最大线程数是 5,当核心线程忙不过来并且队列也满了,线程池最多扩容到 5 个线程。
  • 空闲存活时间是 10 秒,超过核心线程数的临时线程空闲太久会被回收。
  • 阻塞队列容量是 3,用来存放暂时来不及执行的任务。
  • 线程工厂负责创建线程。
  • 拒绝策略决定任务太多时怎么处理,CallerRunsPolicy 表示让提交任务的线程自己执行。

执行没有返回值的任务,可以用 execute:

java 复制代码
Runnable target = new myrun();
pool.execute(target);

执行有返回值的任务,可以用 submit:

java 复制代码
Future<String> s1 = pool.submit(new mythread(100));
System.out.println(s1.get());

提交了多个 Callable 任务,每个任务计算不同范围的累加和。submit 返回 Future,后面通过 get 获取结果。

实际开发中,线程池用完后还要考虑关闭。

八、抢红包练习:共享集合也要加锁

多个用户线程共享同一个红包集合,每次随机取一个红包并删除。

java 复制代码
synchronized (redpocket) {
    if (redpocket.size() == 0) {
        break;
    }

    int index = (int) (Math.random() * redpocket.size());
    Integer money = redpocket.remove(index);
    System.out.println(name + "抢到了" + money + "元");
}

这里锁住的是红包集合对象。原因很简单:判断集合是否为空、随机下标、删除元素,这几个动作必须连在一起执行。

如果不加锁,可能一个线程刚判断集合还有元素,另一个线程就把最后一个红包删掉了。前一个线程再按旧的 size 去 remove,就可能出现下标问题。

这个例子说明,线程安全不只发生在账户余额这种数字上。只要多个线程同时修改同一个集合、对象或变量,都要考虑临界区。

九、怎么验证这些代码

验证线程安全问题时,不要只看一次运行结果。多线程的执行顺序受调度影响,同一段代码多运行几次,结果可能不一样。

可以按这个顺序验证:

  1. 先运行未加锁的取钱案例,观察是否可能出现两个线程都取钱成功。
  2. 再运行同步代码块、同步方法、显式锁三个版本,正常情况下只会有一个线程取钱成功,另一个线程余额不足。
  3. 运行 Callable 示例,观察主线程能否拿到计算结果。
  4. 运行线程池 submit 示例,观察返回结果里是否带有线程名称和计算结果。
  5. 运行抢红包案例,观察红包是否被多个线程抢完,并且不会重复删除同一个红包。

验证时重点看共享数据是否被重复修改,而不是只看控制台有没有报错。

总结

先知道线程怎么创建,再知道线程怎么暂停、命名、插队,然后用共享账户发现安全问题,最后通过加锁和线程池把代码写得更可靠。

多线程代码最重要的判断方法是:有没有多个线程同时修改同一份数据。如果有,就要考虑锁;如果任务数量很多,就不要一直手动 new Thread,而应该交给线程池管理。

参考依据

相关推荐
做个文艺程序员1 小时前
第01篇:Redis 从入门到上手:核心数据结构与 Java Spring Boot 实战详解
java·redis数据结构·redis入门·redis教程·java集成redis
影寂ldy1 小时前
C# 多接口、同名冲突、显式实现、接口继承 完整笔记
java·笔记·c#
JAVA面经实录9171 小时前
Spring Cloud Alibaba 微服务企业实战完整文档(架构+规范+调优+故障+源码)
java·运维·spring cloud·微服务
布局呆星1 小时前
Spring Boot + JWT + Spring Security 认证授权实战:双角色、双 Token、方法级权限,一次讲透
java·开发语言
csdndeyeye1 小时前
从Ctrl+C/V到一键填充:AI投简历工具实测
c语言·开发语言·自动化·秋招·ai助手·网申·ai投简历
大G的笔记本1 小时前
生产级 Spring Boot 网关完整实现方案
java·笔记·gateway
LucianaiB1 小时前
Swarm管理面板的多项目配置策略与模型别名机制的效率分析
java·服务器·前端
诸葛大钢铁1 小时前
如何降低Word文件的体积?压缩Word文件的三种方法
开发语言·c#
小白学大数据1 小时前
如何自动追踪 eBay 售价?Python 爬虫实战解析
开发语言·人工智能·爬虫·python