前言
学习多线程时,最容易把知识点背成清单:创建线程有几种方式、锁有几种写法、线程池有几个参数。真正写代码时,更重要的是把它们串起来:线程负责执行任务,多个线程操作共享数据时会出现安全问题,任务多了以后再交给线程池统一管理。
一、创建线程的三种方式
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,就可能出现下标问题。
这个例子说明,线程安全不只发生在账户余额这种数字上。只要多个线程同时修改同一个集合、对象或变量,都要考虑临界区。
九、怎么验证这些代码
验证线程安全问题时,不要只看一次运行结果。多线程的执行顺序受调度影响,同一段代码多运行几次,结果可能不一样。
可以按这个顺序验证:
- 先运行未加锁的取钱案例,观察是否可能出现两个线程都取钱成功。
- 再运行同步代码块、同步方法、显式锁三个版本,正常情况下只会有一个线程取钱成功,另一个线程余额不足。
- 运行 Callable 示例,观察主线程能否拿到计算结果。
- 运行线程池 submit 示例,观察返回结果里是否带有线程名称和计算结果。
- 运行抢红包案例,观察红包是否被多个线程抢完,并且不会重复删除同一个红包。
验证时重点看共享数据是否被重复修改,而不是只看控制台有没有报错。
总结
先知道线程怎么创建,再知道线程怎么暂停、命名、插队,然后用共享账户发现安全问题,最后通过加锁和线程池把代码写得更可靠。
多线程代码最重要的判断方法是:有没有多个线程同时修改同一份数据。如果有,就要考虑锁;如果任务数量很多,就不要一直手动 new Thread,而应该交给线程池管理。