概念
Java多线程编程是Java中并发编程的重要组成部分,允许在一个Java应用程序中同时执行多个执行线程。以下是关于Java多线程的基本概念和技术要点:
-
线程与进程:
- 进程:是操作系统分配资源的基本单位,每个进程有自己的虚拟地址空间、系统资源(如文件描述符、信号量等)以及独立的执行上下文。
- 线程:是操作系统进行调度和执行的最小单位,它是进程中单一顺序的控制流。同一进程内的多个线程共享相同的地址空间和其他资源,使得线程间的通信和数据共享更为容易。
-
创建线程的几种方式:
-
继承
java.lang.Thread
类:通过创建一个继承自Thread类的新类,并重写run()
方法定义线程执行体。public class MyThread extends Thread { @Override public void run() { // 这里编写线程执行的代码 } public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); // 启动线程 } }
-
实现
java.lang.Runnable
接口:创建一个实现Runnable接口的类,并重写run()
方法,然后将其实例传给Thread构造器创建线程。public class MyRunnable implements Runnable { @Override public void run() { // 这里编写线程执行的代码 } public static void main(String[] args) { Thread thread = new Thread(new MyRunnable()); thread.start(); } }
-
实现
java.util.concurrent.Callable
接口:这种方式下线程返回一个结果,并可能抛出异常。Callable任务通常结合FutureTask
和ExecutorService
使用,而非直接调用start()
方法启动。public class MyCallable implements Callable<String> { @Override public String call() throws Exception { // 这里编写线程执行的代码,并返回结果 return "Result from callable"; } public static void main(String[] args) throws ExecutionException, InterruptedException { ExecutorService executor = Executors.newSingleThreadExecutor(); Future<String> future = executor.submit(new MyCallable()); String result = future.get(); // 获取线程计算的结果 executor.shutdown(); } }
-
-
线程同步与协作:
- 同步机制:Java提供了多种同步工具,包括
synchronized
关键字、volatile
关键字、ReentrantLock
、Semaphore
、CyclicBarrier
、CountDownLatch
等,用于解决多线程环境下的数据一致性问题和同步控制问题。 - 等待/通知机制:线程可以通过
wait()
、notify()
和notifyAll()
方法协调工作,以实现线程间的协作。
- 同步机制:Java提供了多种同步工具,包括
-
线程池:为了更好地管理和复用线程资源,Java提供了
java.util.concurrent.ExecutorService
及其相关类,可以创建不同类型的线程池,如FixedThreadPool、CachedThreadPool等。 -
并发容器与工具类:Java集合框架提供了多种并发容器,比如
ConcurrentHashMap
、CopyOnWriteArrayList
等,它们在内部设计上支持线程安全操作。
通过合理运用多线程技术,可以有效地利用多核处理器的计算能力,提高程序性能,处理并发请求,或者执行异步任务等。但同时也需要注意处理好线程安全问题,避免竞态条件和死锁等问题的发生。
多线程与锁
线程安全是指在多线程环境下,当多个线程访问某个类时,这个类始终能表现出正确的行为,即无论运行时环境采用何种调度方式或者这些线程如何交替执行,该类都能保证其内部状态的一致性。
下面通过一个简单的银行转账案例来说明线程安全问题以及如何解决:
假设有一个Bank类,其中有一个存款账户对象account,包含余额balance属性。有两个线程A和B分别代表两个用户进行转账操作。
public class Bank {
private Account account;
public Bank(int initialBalance) {
this.account = new Account(initialBalance);
}
public void transferMoney(int amount, String fromUser, String toUser) {
int fromBalance = account.getBalance();
if (fromBalance >= amount) {
// 模拟网络延迟或其它耗时操作
try { Thread.sleep(100); } catch (InterruptedException e) {}
account.debit(amount); // A线程扣款
account.credit(amount); // B线程收款
} else {
System.out.println("Insufficient balance for user: " + fromUser);
}
}
class Account {
private int balance;
public Account(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public void debit(int amount) {
balance -= amount; // 扣款操作
}
public void credit(int amount) {
balance += amount; // 入账操作
}
}
}
在这个例子中,如果两个线程同时进行转账操作,可能会出现以下问题:
- 线程A执行到
account.debit(amount)
后由于CPU时间片切换,线程B开始执行并完成account.credit(amount)
,此时线程A再恢复执行,实际上就造成了账户金额凭空增加了amount
。
为了解决这个问题,我们需要对扣款和入账操作进行同步控制,确保在同一时刻只有一个线程能够修改账户余额。一种解决办法是使用synchronized
关键字:
class Account {
private int balance;
public synchronized void debit(int amount) {
if (balance >= amount) {
balance -= amount;
}
}
public synchronized void credit(int amount) {
balance += amount;
}
}
现在,debit()
和credit()
方法都变成了同步方法,同一时间只能有一个线程进入并执行,这样就保证了转账操作的线程安全性。不过实际开发中,可能还需要根据具体业务场景选择更合适的同步策略,例如使用AtomicInteger
原子类,或者显式地锁定特定对象等。
线程状态
在Java多线程编程中,wait()
, notify()
, notifyAll()
和 sleep()
方法都是用来控制线程行为的关键方法,它们主要用于线程间的同步和通信。
案例说明:
假设我们有一个简单的生产者消费者模型,这里有一个共享资源(一个缓冲区),生产者线程负责向缓冲区添加产品,而消费者线程负责从缓冲区移除并消费产品。当缓冲区满时,生产者应停止生产并等待;当缓冲区为空时,消费者应停止消费并等待。我们可以使用 wait()
和 notify()
或 notifyAll()
方法来实现这一同步逻辑。
import java.util.LinkedList;
public class Buffer {
private LinkedList<Object> buffer;
private final int MAX_CAPACITY;
public Buffer(int capacity) {
buffer = new LinkedList<>();
MAX_CAPACITY = capacity;
}
// 生产者方法
public synchronized void produce(Object item) {
while (buffer.size() == MAX_CAPACITY) {
try {
// 缓冲区已满,生产者线程等待
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
buffer.add(item);
System.out.println("Produced an item. Current size: " + buffer.size());
// 添加完成后,通知可能在等待的消费者线程
notifyAll();
}
// 消费者方法
public synchronized void consume() {
while (buffer.isEmpty()) {
try {
// 缓冲区为空,消费者线程等待
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Object item = buffer.removeFirst();
System.out.println("Consumed an item. Current size: " + buffer.size());
// 移除项目后,通知可能在等待的生产者线程
notifyAll();
}
}
sleep() 与 wait() 的区别:
-
sleep():
- 定义在
java.lang.Thread
类中,是一个静态方法,调用Thread.sleep(time)
可以使当前线程暂停执行指定的毫秒数。 - 线程进入 TIMED_WAITING 状态,但不释放任何锁资源,即使在同步代码块中调用
sleep()
,其他线程也无法获取该锁。 sleep()
需要捕获InterruptedException
异常。
- 定义在
-
wait():
- 定义在
java.lang.Object
类中,是实例方法,只有在当前线程已经获得对象的监视器锁(即在synchronized代码块或方法中)时才能调用obj.wait()
。 - 当调用
wait()
时,线程会释放当前对象的锁,进入 WAITING 状态,不再执行后续代码,直到被其他线程通过notify()
或notifyAll()
唤醒。 - 被唤醒后,线程需重新竞争获取锁才能继续执行。
- 调用
wait()
无需捕获InterruptedException
异常,但建议这样做以确保线程生命周期管理的健壮性。
- 定义在
sleep()
主要是用来控制线程休眠一段时间,而不涉及线程间同步的问题;而 wait()
是在线程间通信和同步中使用的,它会使线程放弃对象锁并进入等待状态,直到收到其他线程的通知。
锁
在Java多线程编程中,锁是一种同步机制,用于保护共享资源在并发访问时的一致性和完整性。最常见的是基于对象监视器的内置锁(也称为互斥锁或悲观锁)。以下是一个简单的银行转账案例来阐述锁的作用:
public class BankAccount {
private double balance;
private final Object lock = new Object();
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public void deposit(double amount) {
synchronized (lock) {
balance += amount;
System.out.println(Thread.currentThread().getName() + " deposited " + amount + ". New balance: " + balance);
}
}
public void withdraw(double amount) {
synchronized (lock) {
if (balance >= amount) {
balance -= amount;
System.out.println(Thread.currentThread().getName() + " withdrew " + amount + ". New balance: " + balance);
} else {
System.out.println("Insufficient balance.");
}
}
}
}
public class Main {
public static void main(String[] args) {
BankAccount account = new BankAccount(1000);
Thread t1 = new Thread(() -> account.deposit(500));
Thread t2 = new Thread(() -> account.withdraw(200));
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在这个案例中:
-
我们创建了一个
BankAccount
类,它有一个私有的余额变量balance
,并且定义了一个对象监视器lock
作为锁对象。 -
在
deposit
和withdraw
方法中,我们都使用了synchronized
关键字修饰代码块,并且传入了同一个锁对象lock
。 -
当一个线程进入
synchronized
代码块时,会自动获取锁,其他试图进入该代码块的线程会被阻塞,直到持有锁的线程退出synchronized
代码块并释放锁。 -
因此,在上述案例中,无论是存款还是取款操作,都必须先获取锁才能执行。这就保证了在并发环境下,余额不会因多个线程同时读写而产生错误,实现了线程安全。
除了内置锁,Java还提供了更灵活的锁机制,如 java.util.concurrent.locks.ReentrantLock
,它可以实现公平锁、非公平锁,支持尝试获取锁、可中断锁和定时锁等特性,进一步满足复杂场景下的同步需求。
synchronized 关键字
synchronized
是 Java 语言中用于处理并发编程中线程同步的一种关键字,它的主要目的是为了确保多线程环境下共享资源的正确访问顺序以及状态的一致性,防止多个线程同时访问和修改同一数据时出现竞态条件和数据不一致的情况。
synchronized
主要有以下几种用途和特性:
-
互斥性: 当一个线程进入由
synchronized
修饰的方法或代码块时,其他任何线程都无法同时进入该方法或代码块。这意味着在同一时间,只允许一个线程执行被synchronized
保护的代码部分。-
方法级别:直接在方法声明前加上
synchronized
关键字,意味着整个方法体都被同步。public synchronized void method() { // 这里的代码在任何时候仅能由一个线程执行 }
-
代码块级别:使用
synchronized(obj)
语句块,其中obj
是任意对象引用,通常是一个共享资源的对象实例。public void method() { synchronized (this) { // 这个代码块在同一时间只能被一个持有 this 对象锁的线程执行 } }
-
-
可见性:
synchronized
能够确保在某个线程修改了共享变量的值后,其他线程能够看到这些变化。这是因为当线程离开synchronized
区域时,它会将所有在该区域内部更改过的变量刷新回主内存,而当其他线程进入synchronized
区域时,会强制从主内存加载最新的变量值。 -
有序性: 使用
synchronized
可以保证在程序执行时遵循一定的内存可见性约束,比如“解锁前的写入操作对后续的加锁操作可见”,这有助于解决由于编译器优化和处理器重排序带来的并发问题。 -
可重入性: Java 中的
synchronized
锁是可重入的,这意味着已经持有某对象锁的线程可以再次进入该对象上的synchronized
区域,不会因为自己已经持有锁而被阻塞。 -
内存模型保证: 使用
synchronized
不仅可以实现互斥,还可以隐式地实现 happens-before 规则,即在锁的获取和释放之间存在一种happens-before关系,这对于理解并发程序的行为至关重要。
因此,在设计并发程序时,开发者常利用 synchronized
来确保关键数据结构的线程安全性,尤其是在高并发场景下需要保证数据完整性和一致性时。不过,需要注意的是过度或不当使用 synchronized
可能会导致性能下降或死锁等问题,所以在实际应用中还需要结合具体场景考虑是否采用更高级的并发工具,如 java.util.concurrent
包下的 Lock 接口及其实现类。
死锁
在Java多线程编程中,死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法向前推进。以下是一个简单的死锁示例:
public class DeadlockExample {
static class Friend {
private final String name;
private final Friend friend;
public Friend(String name, Friend friend) {
this.name = name;
this.friend = friend;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s 向我鞠躬!\n", this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: 我向 %s 鞠躬!\n", this.name, bower.getName());
}
}
public static void main(String[] args) {
Friend alphonse = new Friend("Alphonse", new Friend("Gaston"));
Friend gaston = new Friend("Gaston", alphonse);
new Thread(() -> alphonse.bow(gaston)).start();
new Thread(() -> gaston.bow(alphonse)).start();
}
}
在上述代码中,我们创建了两个朋友对象 Alphonse 和 Gaston,每个朋友都有另一个朋友作为他的朋友。每个 bow()
方法都会调用对方的 bowBack()
方法,而且这两个方法都被 synchronized
关键字修饰,意味着在同一时间只能有一个线程执行。
当我们在两个不同的线程中分别让 Alphonse 向 Gaston 鞠躬,同时 Gaston 向 Alphonse 鞠躬时,就会发生死锁:
- 线程1(Alphonse线程)获取到了Alphonse对象的锁,准备调用Gaston的
bowBack()
方法。 - 线程2(Gaston线程)获取到了Gaston对象的锁,准备调用Alphonse的
bowBack()
方法。 - 此时,线程1需要获取Gaston对象的锁以便继续执行,线程2也需要获取Alphonse对象的锁才能继续,但由于双方都持有对方需要的锁,所以形成了死锁,两个线程都无法继续执行下去。
为了避免死锁,通常需要遵循四个原则之一:互斥、占有并等待、无剥夺、循环等待,并尽量避免在代码中违反这些原则。在实际编程中,可以通过合理的资源分配顺序、超时限制或者使用更高层次的并发工具(如java.util.concurrent
包中的锁)等方式来预防死锁的发生。
相关面试题
请解释什么是死锁,并提供一个Java多线程环境下的死锁示例
答: 死锁是指在多线程环境中,两个或更多的线程在执行过程中,因争夺资源而造成的一种互相等待的状态,导致这些线程都无法继续执行。每个线程都在等待其他线程释放资源,从而形成一个环状依赖,没有线程能够打破这种僵局。
Java死锁示例:
public class DeadlockExample {
static class Resource {
private final int id;
public Resource(int id) {
this.id = id;
}
public synchronized void acquire() {
System.out.println(Thread.currentThread().getName() + " acquired resource " + id);
}
public synchronized void release() {
System.out.println(Thread.currentThread().getName() + " released resource " + id);
}
}
public static void main(String[] args) {
Resource r1 = new Resource(1);
Resource r2 = new Resource(2);
Thread t1 = new Thread(() -> {
r1.acquire();
System.out.println("Thread 1 trying to acquire resource 2");
r2.acquire();
r2.release();
r1.release();
}, "Thread 1");
Thread t2 = new Thread(() -> {
r2.acquire();
System.out.println("Thread 2 trying to acquire resource 1");
r1.acquire();
r1.release();
r2.release();
}, "Thread 2");
t1.start();
t2.start();
}
}
在这个例子中,线程1首先获取资源r1,然后尝试获取资源r2,同时线程2首先获取资源r2,然后尝试获取资源r1。当线程1持有r1且正在等待r2时,线程2持有了r2且正在等待r1,形成了死锁。
简述Java中synchronized
关键字的作用和原理
答: synchronized
关键字在Java中主要用于实现线程间的同步,它可以应用于方法或代码块。作用主要有三个:
-
互斥:当一个线程进入被
synchronized
修饰的方法或代码块时,其他试图进入该方法或代码块的线程将会被阻塞,直到拥有锁的线程退出该方法或代码块,释放锁。 -
可见性:确保对共享变量的更新对于其他线程立即可见,这是因为它能确保一个线程对共享变量的修改在该线程释放锁之前对其他线程是可见的。
-
有序性:通过
synchronized
的使用,可以实现先行发生原则(Happens-Before),保证了在锁的获取和释放之间存在一种happens-before关系,从而避免指令重排序引起的并发问题。
原理方面,synchronized
是基于Java对象头的Monitor实现的,每个Java对象都可以关联一个Monitor(监视器锁),当线程试图进入synchronized
代码块或方法时,会请求Monitor的所有权,也就是获取锁。如果锁已被其他线程持有,则线程进入等待队列,直到锁被释放。在释放锁时,会选择一个处于等待队列中的线程授予锁的所有权。
当然,接下来是一些与Java多线程相关的面试题目及简要答案:
Java中如何实现线程安全的单例模式?
答: 在Java中,双重检查锁定(Double-Checked Locking)是一种常用的创建线程安全单例的技巧。以下是一个使用volatile
关键字配合if-else
和同步块实现的线程安全单例模式:
public class Singleton {
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
什么是线程饥饿?给出一个可能导致线程饥饿的例子。
答: 线程饥饿指的是一个或多个线程因为无法获取所需的资源(如锁)而长时间无法执行的情况。例如,在一个优先级抢占式的线程调度系统中,如果有高优先级的线程不断占用CPU,那么低优先级的线程就可能会一直得不到执行机会,从而导致饥饿。
Java中的wait()
、notify()
和notifyAll()
方法是在什么情况下调用的?
答: 这三个方法都是Object类提供的,用于线程间的通信和同步。
wait()
:当线程在持有对象锁的情况下调用此方法,会释放对象锁并进入等待状态,直到其他线程调用同一对象的notify()
或notifyAll()
方法唤醒该线程。notify()
:唤醒在此对象监视器上等待的一个随机线程(如果有多个线程在等待,则不确定哪一个会被唤醒)。notifyAll()
:唤醒在此对象监视器上等待的所有线程。
通常在生产者消费者模型或其他线程协作场景中使用这些方法,以确保线程在适当的时机被唤醒和执行。
解释一下Java中的ThreadLocal
类的作用和应用场景。
答: ThreadLocal
类提供了一种线程局部存储机制,可以在每个线程中存储独立的副本变量,各个线程之间互不影响。在多线程环境下,每个线程都有自己独立的ThreadLocal
变量副本,因此可以避免线程之间的数据共享问题,减少同步的需要。典型的应用场景包括数据库连接、线程本地事务管理、每个线程独立的日志记录上下文等。
Java中的Atomic
类有哪些作用?
答: Java的java.util.concurrent.atomic
包中提供了一系列的原子操作类,如AtomicInteger
、AtomicLong
、AtomicReference
等。它们提供了原子级别的操作,能够在不使用锁的情况下保证数据的线程安全性,如递增、递减、比较并交换等操作。这些类在高性能并发编程中非常有用,因为它们避免了锁的开销,提高了系统的整体性能。
多线程在安卓开发中的应用场景
在安卓(Android)开发中,多线程技术有着广泛的应用,以下是多线程在安卓应用开发中的一些重要应用场景:
-
网络请求处理:
- Android应用经常需要进行网络通信,例如从服务器获取数据、上传图片或文件等。网络IO操作通常耗时较长,若在网络请求期间阻止主线程(UI线程)会导致界面卡顿或无响应。因此,使用子线程进行网络请求并在请求完成后通过回调或消息机制告知主线程更新界面是一种标准做法。可以使用
AsyncTask
、Retrofit
配合OkHttp
的异步API,或者自定义线程和Handler进行网络通信。
- Android应用经常需要进行网络通信,例如从服务器获取数据、上传图片或文件等。网络IO操作通常耗时较长,若在网络请求期间阻止主线程(UI线程)会导致界面卡顿或无响应。因此,使用子线程进行网络请求并在请求完成后通过回调或消息机制告知主线程更新界面是一种标准做法。可以使用
-
图片加载与显示:
- 加载大图或网络图片时,如果不进行异步处理,图片解码过程会在主线程中进行,同样会造成UI卡顿。为此,可以使用
BitmapFactory.decode...
等方法在后台线程中加载图片,完成后通过ImageView
的post()
方法更新视图,或者借助成熟的第三方库如Glide
、Picasso
等,它们内部已经妥善处理了多线程加载与展示图片的工作。
- 加载大图或网络图片时,如果不进行异步处理,图片解码过程会在主线程中进行,同样会造成UI卡顿。为此,可以使用
-
数据库操作:
- SQLite数据库操作,尤其是批量插入、查询、更新和删除等操作,如果在主线程执行会阻塞UI,影响用户体验。因此,数据库操作通常应在后台线程中进行,完成后通过Handler或LiveData传递结果到主线程更新UI。
-
文件读写与大量数据处理:
- 文件读写、压缩解压、大数据处理等耗时操作应当在子线程中执行,避免阻塞主线程。完成后,通知主线程完成UI更新或者其他必要的操作。
-
UI渲染与动画处理:
- 虽然Android的UI操作必须在主线程中执行,但复杂的计算或者绘制工作可以通过额外的Worker线程完成,准备好数据后再回到主线程进行渲染或动画播放。
-
后台服务与定时任务:
- 使用
IntentService
或JobScheduler
等组件在后台执行长期任务或定期任务,这些任务与主线程分离,确保用户界面保持流畅。
- 使用
-
并发任务执行:
- 如果应用需要同时执行多个并发任务,并在所有任务完成后统一处理结果,可以使用
ExecutorService
结合Future
或者CountDownLatch
等并发工具类来实现。
- 如果应用需要同时执行多个并发任务,并在所有任务完成后统一处理结果,可以使用
多线程技术在Android开发中扮演着至关重要的角色,帮助开发者构建高性能、响应迅速的应用程序。随着Android API的发展,Google还引入了更多高级并发工具,如HandlerThread
、Looper
、LiveData
、Flow
等,它们为多线程编程提供了更加便捷和高效的解决方案。