Java 多线程指南:从基础用法到线程安全

前言

我们主要看如何使用 Java 的多线程,以及线程安全。

先来看看 Java 多线程的用法。

Java 多线程

Thread

java 复制代码
Thread thread = new Thread(){
    @Override
    public void run() {
        System.out.println("Thread is running");
    }
};
thread.start();

首先,创建 Thread 对象,然后调用该对象的 start()。这样就可以将 run 方法中的代码,放在创建的线程中去执行。

为什么这样就能够将任务放在后台去执行?

进到 start 方法内部,发现调用的是 start0 方法:

java 复制代码
private native void start0();

start0native 修饰,表明该方法的实现和平台相关,是由虚拟机调度操作系统去完成的。

此方法会让虚拟机开启新的线程,然后执行 run 方法的代码。

补充:

进程(Process)是操作系统进行资源分配的基本单位,是一个动态执行的程序及其所有系统资源的集合。

而线程(Thread)是进程内部执行代码的最小单位,一个进程可以包含一个或多个线程。

线程之间可以共享同一进程的资源,但进程之间互相独立。

Runable

java 复制代码
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println("Runnable is running");
    }
};
Thread thread = new Thread(runnable);
thread.start();

创建 Runable 对象,传给 Thread 对象来创建并启动线程。

传入的 Runable 对象会在 Thread.run 方法中被执行。

java 复制代码
// Thread.java
@Override
public void run() {
    Runnable task = holder.task;
    if (task != null) { // task 就是传入的 Runnable 对象
        Object bindings = scopedValueBindings();
        runWith(bindings, task);
    }
}

这两种写法的效果是一样的,但后者的重用性更高。

不过,在实际使用中,这两种方式都不常用。

ThreadFactory

java 复制代码
AtomicInteger count = new AtomicInteger(0);
ThreadFactory threadFactory = new ThreadFactory() {
    @Override
    public Thread newThread(Runnable r) {
        return new Thread(r, "thread-" + (count.incrementAndGet())); // 使用原子类保证 ++count 线程安全
    }
};
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " is running");
    }
};

for (int i = 0; i < 10; i++) {
    Thread thread = threadFactory.newThread(runnable);
    thread.start();
}

ThreadFactory 使用内部 newThread 方法创建线程,常用于统一管理线程的创建,比如统一命名或计数。

Executor

java 复制代码
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println("hello world");
    }
};

Executor executor = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
    executor.execute(runnable);
}

创建线程的工作在 newCachedThreadPool 方法中。

java 复制代码
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

ExecutorServiceshutdownshutdownNow 方法可用于停止所有任务。shutdownNow 是立即停止,会尝试中断正在执行的任务;而 shutdown 是稍后停止,不再接受新任务,会执行完正在执行或者在排队等待的任务。

实际上,是在 ThreadPoolExecutor 这个线程池对象中。

构造方法的参数分别是核心线程数、最大线程数、保持活跃时间,时间单位,任务阻塞队列。如果当前线程数大于核心线程数,那么超出的线程会在空闲达到活跃时间后被回收。

另外,我们可以调用 ExecutorsnewFixedThreadPool 静态方法来创建一个固定线程数量的线程池,可用于处理井喷式任务。

调用 ExecutorsnewSingleThreadExecutor 方法可以创建一个只有一个线程的线程池,可用于处理需要在后台执行的细小任务。

Callable

java 复制代码
Callable<String> callable = new Callable<String>() {
    @Override
    public String call() throws Exception {
        Thread.sleep(3000);
        return "hello world";
    }
};

ExecutorService executor = Executors.newSingleThreadExecutor();
// 提交任务并获取Future对象
Future<String> future = executor.submit(callable);

try {
    // 获取结果 (此方法会阻塞当前线程)
    String result = future.get();
    System.out.println("result is " + result);
} catch (InterruptedException | ExecutionException e) {
    e.printStackTrace();
} finally {
    executor.shutdown();
}

我们可以使用 Callable 来获取线程执行的返回值,但调用 Future.get() 会阻塞当前线程,直到结果返回。

那这么做的意义何在?

其实我们可以在循环中,主动检查任务是否完成,如果未完成就执行其他任务,否则取出结果,这样获取结果的过程就是非阻塞的了。

java 复制代码
while (true) {

    // ... 执行其他任务 ...
    System.out.println("正在执行其他任务...");
    Thread.sleep(500); // 模拟耗时

    if (future.isDone()) { // 检查后台任务是否已完成
        try {
            // 任务已完成,获取返回结果
            String result = future.get();
            System.out.println("result is " + result);
            break;
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        } finally {
            executor.shutdown();
        }
    }
}

线程同步和线程安全

示例一 (可见性问题)

java 复制代码
public class Main {

    public static boolean isRunning = true;

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                while (isRunning) { // 子线程读取 isRunning
                }
            }
        }.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        // 主线程修改 isRunning
        isRunning = false; 
    }

}

按照我们预想来说,程序会在一秒后停止,但实际却不会,这是为什么?

要明白这一点,首先得知道 Java 内存模型:线程和程序都会有自己独立的内存空间,线程的叫工作内存,程序的叫做主内存。当线程需要修改程序中的变量时,会先拷贝一份到本地(工作内存中),在本地修改后,再将修改后的数据同步到主内存中。

在上述代码中,虽然主线程将 isRunning 变量的值修改为了 false,并同步到了主内存中,但子线程却一直持有自己工作内存中的旧数据(isRunning == true),并且没有被通知去主内存中拿取新数据,所以导致子线程中的循环不会停止,这就是线程的可见性问题。

我们可以加上 volatile 关键字来解决,它会保证变量的可见性。

  1. 也就是每次读数据前,强制先从主内存中拿取最新的数据到本地,让本地的缓存失效;

  2. 每次写数据后,强制将最新的本地数据立即同步(刷新)到主内存中。

java 复制代码
public static volatile boolean isRunning = true;

现在运行程序,一秒就会结束。

示例二 (原子性问题)

java 复制代码
public class Main {

    public static int x = 0;

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                x++;
            }
            System.out.println("Thread A: x is " + x);
        }).start();

        new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                x++;
            }
            System.out.println("Thread B: x is " + x);
        }).start();
    }

}

预想中,总有一个线程会打印两百万,但实际却是:

less 复制代码
// 执行第 n 次
Thread A: x is 1315887
Thread B: x is 1315887
// 执行第 n+1 次
Thread A: x is 704550
Thread B: x is 1363200

难道是线程可见性(同步性)的问题吗?我们加上 volatile 关键字试试,发现结果并没有改变。

volatile 只能保证可见性,不能保证原子性。

其实这是因为 x++ 不是一个原子操作,它会先读取 x 的当前值,然后计算 x+1,最后将计算结果赋给 x。

因为 x++ 可被拆分,所以导致了这个问题。

比如当 x=5 时,线程 A 读取 x=5,此时发生线程切换,线程 B 开始执行。B 将三步都执行完(读取、计算、写回),此时主内存中 x=6。切回线程 A,A 会接着执行,它会将读取的旧值 5 计算出结果 6,然后将 6 写回 x。这样就使得,两个线程都执行了 x++,但 x 的值却只增加了 1,导致一次累加丢失。

循环反复,会导致累加不断丢失,就造成了最终结果并不是两百万。

所以我们需要让 x 的读、改、写操作操作合并为原子操作,可以使用 synchronized 关键字:

java 复制代码
public class Main {

    public static int x = 0;

    // 增加同步静态方法
    public synchronized static void increment() {
        x++;
    }

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                increment(); 
            }
            System.out.println("Thread A: x is " + x);
        }).start();

        new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                increment(); 
            }
            System.out.println("Thread B: x is " + x);
        }).start();
    }
}

synchronized 既可以保证可见性,又能保证原子性。它会让该方法同一时刻只能被一个线程执行。

现在,运行就能够看到正确结果,例如:

less 复制代码
Thread A: x is 1868686
Thread B: x is 2000000

我们也可以使用原子类来解决,比如之前看到的 AtomicInteger

java 复制代码
public class Main {
    // 直接使用 AtomicInteger,初始值为 0
    public static AtomicInteger x = new AtomicInteger(0);

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                x.incrementAndGet(); // 调用原子的"加一并获取"方法
            }
            System.out.println("Thread A: x is " + x.get());
        }).start();

        new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                x.incrementAndGet();
            }
            System.out.println("Thread B: x is " + x.get());
        }).start();
    }
}

它的 incrementAndGet() 是原子性的,底层使用 CAS(比较并交换)机制实现。在并发并不激烈时,性能会优于 synchronized 这种重量级锁。

示例三 (Monitor 锁对象)

java 复制代码
public class Main {
    public int count = 0; 
    public int number = 0; 
    public String id;

    private synchronized void add() {
        count++;
        number++;
    }

    private synchronized void minus() {
        count--;
        number--;
    }

    private synchronized void randomId() {
        id = UUID.randomUUID().toString();
    }
}

为了不让修改变量(资源)的方法被多个线程同时访问,且保证线程同步性,我们给每个方法都加上了 synchronized 关键字。

实际上,Java 会给加上了 synchronized 关键字的方法设置监视器 (monitor)。对于非静态方法,默认设置的监视器为当前类对象 (this)。

对于静态方法,默认的监视器是当前类的字节码 (Main.class)。

因为这三个方法共享了同一个监视器,所以会导致有一个线程调用 add 方法时,其他线程无法调用 minus(很合理,因为操作了相同的资源 countnumber), 同时,其他线程也无法调用 randomId 方法。

但这并不是我们想要的,因为修改 id 和修改 count/number 并不排斥,其他线程调用 randomId 方法并不会影响到当前线程的线程安全。

这时需要用到 synchronized 代码块,为不同的资源指定不同的锁(监视器对象):

java 复制代码
public class Main {

    public int count = 0;
    public int number = 0;
    public String id;

    private final Object monitorCountNumber = new Object();
    private final Object monitorId = new Object();

    private void add() {
        synchronized (monitorCountNumber) {
            count++;
            number++;
        }
    }

    private void minus() {
        synchronized (monitorCountNumber) {
            count--;
            number--;
        }
    }

    private void randomId() {
        synchronized (monitorId) {
            id = UUID.randomUUID().toString();
        }
    }
}

这样也能保证线程安全,addminus 方法被一个线程访问时,会锁住 monitorCountNumber;而 randomId 可被另一个线程同时访问,因为它锁的是 monitorId

synchronized 实际上保护的是资源,通过给资源上锁来实现互斥访问。

模型图为:

死锁 (Deadlock)

死锁是指两个或多个线程在执行过程中,因抢夺资源而造成的互相等待的现象。

只有涉及多个锁(单个锁是不会发生的),并且线程获取锁的顺序不一致时,才会发生。例如:

java 复制代码
public class DeadlockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public void runThreadA() {
        synchronized (lockA) { // 线程A 先锁 A
            System.out.println("Thread A got lock A");
            try {
                Thread.sleep(100);
            } catch (Exception e) {
            }
            System.out.println("Thread A trying to get lock B...");
            synchronized (lockB) { // 再尝试锁 B
                System.out.println("Thread A got lock B");
            }
        }
    }

    public void runThreadB() {
        synchronized (lockB) { // 线程B 先锁 B
            System.out.println("Thread B got lock B");
            try {
                Thread.sleep(100);
            } catch (Exception e) {
            }
            System.out.println("Thread B trying to get lock A...");
            synchronized (lockA) { // 再尝试锁 A
                System.out.println("Thread B got lock A");
            }
        }
    }
}

线程 A 尝试锁定锁 B 时,发现锁 B 被线程 B 持有,所以线程 A 会等待;同时,线程 B 尝试锁定锁 A 时,发现锁 A 被线程 A 持有,所以线程 B 会等待。

A 在等 B 释放锁,B 也在等 A 释放锁,两者都无法进行下去,程序被永久挂起了,这就是死锁。

双重检查锁 (DCL)

我们再来看看双重检查锁机制,它主要用于懒加载的单例模式,例如:

java 复制代码
public class Singleton {
    // 必须加 volatile 关键字
    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        // 第一次检查:如果已存在,避免不必要的同步,提高性能
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次检查:确保锁内的线程只创建一次实例
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么代码是这样的?

java 复制代码
public class Singleton {
    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

正常来说这样就行了,但如果有两个线程同时访问 getInstance() 方法,同时执行到了 if (instance == null) 语句,都判断为 true,这样会导致 Singleton 实例被先后创建两次,破坏单例。

你当然可以给整个方法加上 synchronized 关键字,但这会降低性能。

java 复制代码
public static Singleton getInstance() {
    if (instance == null) { // 第一次检查
        synchronized (Singleton.class) {
            instance = new Singleton();
        }
    }
    return instance;
}

如果只是给创建实例的代码加上锁,还是会导致问题:两个线程都进入了外层的 if 检查。A 拿到锁,会创建实例,A 释放锁后,B 会拿到锁,又会创建一个实例。

所以需要将第二次检查实例是否为空的代码加上:

java 复制代码
public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            // 第二次检查:确保实例只创建一次
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

现在还有一个问题,instance = new Singleton() 并非原子操作。它会分为三步:

  1. 为 instance 变量分配内存空间
  2. 调用 Singleton 的构造函数,初始化字段
  3. 让 instance 指向分配的内存地址

JVM 为了优化,可能会进行指令重排,将执行的顺序改变,变为 1->3->2。

当线程 A 执行完 1 和 3 后,线程 B 执行到最外层 if 语句时,会认为此时 instance 不为 null,直接返回这个实例。虽然 instance 指向了内存地址,但对象内部的字段还未初始化,这会导致后续使用出错。

所以还要加上 volatile 关键字,它能够禁止这种指令重排,保证其他线程拿到的必定是完整的、初始化过的实例。

这就是为什么最终代码会是这样的。

读写锁

另外我们可以使用可重入锁 (ReentrantLock),来手动上锁和释放锁,像这样:

java 复制代码
ReentrantLock lock = new ReentrantLock();

public void func(){
    // 上锁
    lock.lock();

    try {
        // 必须放在 try...finally 中,确保锁一定会被释放
        System.out.println("do something");
    } finally {
        // 释放锁
        lock.unlock();
    }
}

为什么要用它,因为它更灵活,比如可以派生出读写锁 (ReentrantReadWriteLock)。

我们来分析一下线程安全问题会发现:

  • 在一个线程写操作时,其他线程不能进行写操作;
  • 在一个线程写操作时,其他线程不能进行读操作;
  • 在一个线程读操作时,其他线程也不能进行写操作;
  • 但一个线程进行读操作时,其他线程也能进行读操作。

synchronizedReentrantLock 都是独占锁,无法区分读写,只要上锁,读和写都会被阻塞。但在读多写少的场景中,允许读-读并发,就能极大地提高性能。

这时,可重用读写锁就派上用处了,它对资源有着更加精细的控制:

java 复制代码
public class ReadWriteDemo {
    private String data = "initial data";

    // 创建一个读写锁
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    // 从中获取读锁
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    // 从中获取写锁
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

    // 读操作,使用读锁(允许多个线程同时读)
    public void printData() {
        readLock.lock();
        try {
            System.out.println(data);
        } finally {
            readLock.unlock();
        }
    }

    // 写操作,使用写锁(独占,同一时间只允许一个线程写)
    public void setData(String data) {
        writeLock.lock();
        try {
            this.data = data;
        } finally {
            writeLock.unlock();
        }
    }
}
相关推荐
Hungry_Shark2 小时前
IDEA版本控制管理之使用Gitee
java·gitee·intellij-idea
赛姐在努力.2 小时前
《IDEA 突然“三无”?三秒找回消失的绿色启动键、主菜单和项目树!》
java·intellij-idea
猎板PCB黄浩2 小时前
从废料到碳减排:猎板 PCB 埋容埋阻的绿色制造革命,如何实现环保与性能双赢
java·服务器·制造
ZzzK,2 小时前
JAVA虚拟机(JVM)
java·linux·jvm
西红柿维生素2 小时前
JVM相关总结
java·jvm·算法
00后程序员张3 小时前
详细解析苹果iOS应用上架到App Store的完整步骤与指南
android·ios·小程序·https·uni-app·iphone·webview
coderxiaohan3 小时前
【C++】类和对象1
java·开发语言·c++
程序员江同学4 小时前
ovCompose + AI 开发跨三端的 Now in Kotlin App
android·kotlin·harmonyos