Java 线程编程全解析
1. 静态方法同步
在 Java 中,静态方法也可以被声明为同步方法。此时,同步机制是通过使用
java.lang.Class
类对应的类对象的隐式互斥变量(类互斥变量)来实现的。在 Java 程序中定义的每个类都会自动生成一个该类的对象。
静态方法和非静态方法使用不同的隐式互斥变量来实现同步。一个静态同步方法可以通过使用该类的对象进行同步块操作,或者激活该类对象的同步非静态方法,来获取类对象和该类实例对象的互斥变量。同样,一个同步非静态方法也可以通过调用同步静态方法来获取对象和类对象的互斥变量。
对于任意类
Cl
,可以直接使用类互斥变量进行同步块操作,示例代码如下:
synchronized (Cl.class) {/*Code*/}
2. 等待与通知机制
在某些情况下,线程需要等待某个事件或条件的发生。一旦事件发生,线程就会执行预定义的操作。线程会一直等待,直到事件发生或条件满足。这个事件可以由另一个线程发出信号,同样,另一个线程也可以使条件得到满足。
Java 通过预定义的
Object
类的
wait()
和
notify()
方法提供了类似的机制。这些方法可用于任何显式或隐式继承自
Object
类的对象。这两个方法只能在同步块或同步方法中使用。
wait()
方法的典型使用模式如下:
synchronized (lockObject) {
while (!condition) { lockObject.wait(); }
Action();
}
调用
wait()
方法会阻塞调用线程,直到另一个线程为同一个对象调用
notify()
方法。当线程调用
wait()
方法被阻塞时,它会释放用于同步周围同步方法或块的对象的隐式互斥变量,这样其他线程就可以获取该互斥变量。
多个线程可能会阻塞等待同一个对象。每个对象都会维护一个等待线程的列表。当另一个线程调用同一个对象的
notify()
方法时,该对象的一个等待线程会被唤醒并继续运行。在恢复执行之前,该线程首先会获取对象的隐式互斥变量。如果获取成功,线程将执行程序中指定的操作;如果获取失败,线程将阻塞并等待,直到拥有互斥变量的线程离开同步方法或块释放该互斥变量。
wait()
和
notify()
方法的工作方式类似于 Pthreads 中条件变量的
pthread_cond_wait()
和
pthread_cond_signal()
操作。
wait()
和
notify()
方法使用每个对象的隐式等待队列来实现,该等待队列包含所有等待被
notify()
操作唤醒的阻塞线程,但不包含那些因等待对象的隐式互斥变量而被阻塞的线程。
Java 语言规范并未指定当另一个线程调用
notify()
方法时,等待队列中的哪个线程会被唤醒。可以使用
notifyAll()
方法唤醒等待队列中的所有线程,这类似于 Pthreads 中的
pthread_cond_broadcast()
操作。
notifyAll()
方法也必须在同步块或同步方法中调用。
3. 生产者 - 消费者模式
上述 Java 的等待和通知机制可用于实现生产者 - 消费者模式,使用一个固定大小的物品缓冲区。生产者线程可以将新物品放入缓冲区,消费者线程可以从缓冲区中取出物品。
下面是一个使用
wait()
和
notify()
方法实现线程安全缓冲区机制的示例类
BoundedBufferSignal
:
// 此处省略 BoundedBufferSignal 类的完整代码,其包含 put 和 take 方法等
当创建
BoundedBufferSignal
类的对象时,会生成一个指定大小的数组作为缓冲区。
putptr
和
takeptr
索引分别表示缓冲区中放入和取出下一个物品的位置,这些索引以循环方式使用。
该类提供了
put()
方法让生产者将物品放入缓冲区,以及
take()
方法让消费者从缓冲区中取出物品。缓冲区对象有三种状态:满、部分满和空,具体状态及条件如下表所示:
| 状态 | 条件 | put 操作是否可行 | take 操作是否可行 |
| ---- | ---- | ---- | ---- |
| 满 | size == capacity | 否 | 是 |
| 部分满 | 0 < size < capacity | 是 | 是 |
| 空 | size == 0 | 是 | 否 |
如果缓冲区已满,生产者线程执行
put()
方法会阻塞执行线程,这通过
wait()
操作实现。如果
put()
方法是在缓冲区之前为空的情况下执行,在物品放入缓冲区后,会使用
notifyAll()
方法唤醒所有等待的(消费者)线程。如果缓冲区为空,消费者线程执行
take()
方法会使用
wait()
方法阻塞执行线程。如果
take()
方法是在缓冲区之前为满的情况下执行,在物品从缓冲区取出后,会使用
notifyAll()
方法唤醒所有等待的(生产者)线程。
put()
和
take()
方法的实现确保了
BoundedBufferSignal
类的每个对象可以被任意数量的线程并发访问,而不会出现竞态条件。
4. MyMutex 类的改进
可以使用
wait()
和
notify()
方法改进
MyMutex
类,避免
getMyMutex()
方法中的主动等待。改进后的实现还实现了嵌套锁定机制,允许同一个线程对同步对象进行多次锁定。
在改进的实现中,使用
lockCount
变量来统计锁定次数,该变量初始化为 0,每次调用
getMyMutex()
或
freeMyMutex()
时分别进行递增或递减操作。
getMyMutex()
方法现在也被声明为同步方法。在之前的实现中,这可能会导致死锁,但在改进的实现中,由于调用
wait()
方法会在线程挂起并插入对象的等待队列之前释放隐式互斥变量,因此不会发生死锁。
5. 屏障同步
屏障同步是一个同步点,每个线程都会在此等待,直到所有参与的线程都到达该同步点,然后线程才会继续执行。在 Java 中,可以使用
wait()
和
notify()
方法实现屏障同步。
以下是一个
Barrier
类的示例:
// 此处省略 Barrier 类的完整代码,包含构造函数和 waitForRest 方法等
Barrier
类包含一个构造函数,用于初始化一个
Barrier
对象,并指定需要等待的线程数量。实际的同步操作由
waitForRest()
方法提供,每个线程在预期的同步点都必须调用该方法。在该方法中,每个线程会递减
t2w4
变量,如果
t2w4
大于 0,则调用
wait()
方法,这会阻塞每个到达
Barrier
对象的线程。最后到达的线程会使用
notifyAll()
方法唤醒所有等待的线程。
Barrier
类的对象只能使用一次,因为在同步过程中,同步计数器
t2w4
会递减到 0。
6. 条件变量
Java 中的
wait()
和
notify()
机制与 Pthreads 中的条件变量同步机制有一些相似之处。主要区别在于,
wait()
和
notify()
方法由通用的
Object
类提供,因此该机制隐式绑定到调用
wait()
和
notify()
方法的对象的内部互斥变量。这避免了在使用 Pthreads 对应机制时需要显式关联互斥变量,从而方便了该机制的使用。但
wait()
和
notify()
方法固定绑定到特定的互斥变量,也降低了灵活性,因为无法将任意互斥变量与对象的等待队列组合使用。
当调用
wait()
或
notify()
方法时,Java 线程必须是对应对象互斥变量的所有者,否则会抛出
IllegalMonitorStateException
异常。使用
wait()
和
notify()
机制无法使用同一个互斥变量对不同对象的等待队列进行同步。不过,可以使用
wait()
和
notify()
方法实现一个新的类,模拟 Pthreads 中条件变量的机制。
以下是一个
CondVar
类的示例:
// 此处省略 CondVar 类的完整代码,包含 cvWait、cvSignal 和 cvBroadcast 方法等
CondVar
类提供了
cvWait()
、
cvSignal()
和
cvBroadcast()
方法,分别模拟
pthread_cond_wait()
、
pthread_cond_signal()
和
pthread_cond_broadcast()
的行为。这些方法允许使用任意互斥变量进行同步,该互斥变量作为
MyMutex
类型的参数提供给每个方法。因此,一个
MyMutex
类型的互斥变量可以用于多个
CondVar
类型的条件变量的同步。
调用
cvWait()
方法时,线程会被阻塞并放入相应
CondVar
对象的等待队列中。
cvWait()
方法内部的同步操作使用该对象的内部互斥变量。
CondVar
类还允许将使用条件变量的 Pthreads 程序简单地移植到 Java 程序中。
下面是使用
CondVar
类实现生产者 - 消费者线程缓冲区机制的示例:
// 此处省略使用 CondVar 类实现缓冲区机制的完整代码,包含 put 和 take 方法等
生产者线程可以使用
put()
方法将对象插入缓冲区,消费者线程可以使用
take()
方法从缓冲区中取出对象。
CondVar
类型的条件对象
notFull
和
notEmpty
使用同一个互斥变量
mutex
进行同步。
7. 扩展同步模式
Java 提供的同步机制可用于实现更复杂的同步模式,这些模式可用于并行应用程序。以信号量机制为例,通过
wait()
和
notify()
方法可以在 Java 中实现信号量机制。
以下是一个简单的信号量机制实现示例:
// 此处省略 Semaphore 类的完整代码,包含 acquire 和 release 方法等
acquire()
方法会等待(如果需要),直到信号量对象的内部计数器至少达到 1。一旦满足条件,计数器就会递减。
release()
方法会递增计数器,并使用
notify()
方法唤醒一个在
acquire()
方法中因调用
wait()
方法而被阻塞的等待线程。
信号量机制可用于生产者 - 消费者线程的同步。与之前直接使用
wait()
和
notify()
方法实现的缓冲区机制不同,新的实现使用两个单独的
Semaphore
对象进行缓冲区控制。根据具体情况,这可以减少同步开销。
8. Java 线程调度
Java 程序可能由多个线程组成,这些线程可以在执行平台的一个或多个处理器上执行。准备好执行的线程会竞争在空闲处理器上执行。程序员可以通过为线程分配优先级来影响线程到处理器的映射。
Java 线程的最小、最大和默认优先级在
Thread
类的以下字段中指定:
public static final int MIN_PRIORITY // normally 1
public static final int MAX_PRIORITY // normally 10
public static final int NORM_PRIORITY // normally 5
较大的优先级值对应较高的优先级。执行类的
main()
方法的线程默认具有
Thread.NORM_PRIORITY
优先级。新创建的线程默认具有与创建它的线程相同的优先级。可以使用
Thread
类的以下方法来获取或动态更改线程的当前优先级:
public int getPriority();
public int setPriority(int prio);
如果可执行线程的数量多于空闲处理器的数量,JVM 的调度器通常会优先选择优先级较高的线程。具体的线程选择执行机制可能取决于特定 JVM 的实现。Java 规范没有定义确切的调度机制,以增加在不同操作系统和执行平台上实现 JVM 的灵活性。
由于没有确切的线程优先级调度规范,优先级不能用来替代同步机制。相反,优先级只能用于表达不同线程的相对重要性,以便在有疑问时让最重要的线程优先执行。
当使用不同优先级的线程时,可能会出现优先级反转的问题。优先级反转是指一个高优先级线程被阻塞,等待一个低优先级线程,例如因为低优先级线程锁定了高优先级线程试图锁定的同一个互斥变量。当一个中等优先级线程准备好执行时,低优先级线程可能会被阻止继续执行并释放互斥变量,从而导致高优先级线程无法执行,而中等优先级线程优先执行。
可以使用优先级继承来避免优先级反转问题。如果一个高优先级线程被阻塞,例如因为激活了一个同步方法,那么当前控制关键同步对象的线程的优先级会被提高到被阻塞线程的高优先级。这样,就没有中等优先级的线程能够阻止高优先级线程执行。许多 JVM 使用这种方法,但 Java 规范并不保证这一点。
9. java.util.concurrent 包
java.util.concurrent
包提供了基于之前所述标准同步机制(如同步块、
wait()
和
notify()
)的额外同步机制和类。该包从 Java2 平台(Java2 Standard Edition 5.0,J2SE 5.0)开始可用于 Java 平台。
这个包提供了更抽象和灵活的同步操作,包括原子变量、锁变量、屏障同步、条件变量和信号量,以及不同的线程安全数据结构,如队列、哈希映射或数组列表。
以下是该包中一些重要功能的介绍:
9.1 信号量机制
Semaphore
类提供了计数信号量的实现,类似于之前提到的信号量机制。
Semaphore
对象内部维护一个计数器,用于统计许可的数量。
Semaphore
类的重要方法如下:
void acquire();
void release();
boolean tryAcquire()
boolean tryAcquire(int permits, long timeout, TimeUnit unit)
acquire()
方法请求一个许可,如果没有可用许可,调用线程会被阻塞。如果有可用许可,内部许可计数器会递减,控制权返回给调用线程。
release()
方法通过递增内部计数器来添加一个许可,如果有其他线程正在等待该信号量的许可,该线程会被唤醒。
tryAcquire()
请求一个许可,如果有可用许可,调用线程会获取许可并立即返回
true
;如果没有可用许可,立即返回
false
,调用线程不会被阻塞。
9.2 屏障同步
CyclicBarrier
类提供了屏障同步的实现。“cyclic” 前缀表示该类的对象在所有参与线程通过屏障后可以再次使用。
CyclicBarrier
类的构造函数如下:
public CyclicBarrier (int n);
public CyclicBarrier (int n, Runnable action);
第一个构造函数指定需要通过屏障的线程数量
n
,第二个构造函数还可以额外指定一个操作
action
,当所有线程通过屏障后会执行该操作。
CyclicBarrier
类的重要方法有
await()
和
reset()
。
await()
方法使线程在屏障处等待,直到指定数量的线程到达。调用
reset()
方法可以将屏障对象重置为初始状态。
9.3 锁机制
java.util.concurrent.locks
包包含用于锁和等待条件发生的接口和类。
Lock
接口定义了超越标准同步方法和块的锁定机制,并且不限于使用对象的隐式互斥变量进行同步。
Lock
接口的重要方法如下:
void lock();
boolean tryLock();
boolean tryLock (long time, TimeUnit unit);
void unlock();
lock()
方法尝试锁定相应的锁对象。如果锁已经被另一个线程设置,执行线程会被阻塞,直到锁定线程调用
unlock()
方法释放锁。如果调用
lock()
时锁对象未被其他线程设置,执行线程无需等待即可成为锁的所有者。
tryLock()
方法也尝试锁定锁对象,如果成功,返回
true
;如果锁对象已被其他线程设置,返回
false
,调用线程不会被阻塞。
tryLock()
方法还可以指定额外的参数来设置等待时间。
unlock()
方法释放调用线程之前设置的锁。
ReentrantLock
类提供了
Lock
接口的实现。该类的构造函数如下:
public ReentrantLock();
public ReentrantLock (boolean fairness);
第二个构造函数可以指定一个公平性参数
fairness
。如果设置为
true
,当多个线程同时等待同一个锁对象时,等待时间最长的线程可以访问该锁对象。如果不使用公平性参数,则无法假设特定的访问顺序。使用公平性参数可能会导致额外的管理开销,从而降低吞吐量。
9.4 信号机制
java.util.concurrent.lock
包中的
Condition
接口定义了带有条件变量的信号机制,允许线程等待特定条件的发生。条件变量的发生由另一个线程发出信号,类似于 Pthreads 中条件变量的功能。条件变量总是绑定到一个锁对象。
可以通过调用实现
Lock
接口的类的
newCondition()
方法来创建与锁对象关联的条件变量。条件变量的可用方法如下:
void await();
void await (long time, TimeUnit unit);
void signal();
void signalAll();
await()
方法阻塞执行线程,直到被另一个线程的
signal()
方法唤醒。在阻塞之前,执行线程会以原子操作的方式释放锁对象,因此调用
await()
方法之前,执行线程必须是锁对象的所有者。被唤醒后,线程必须再次尝试设置锁对象,只有成功后才能继续执行计算。
await()
方法还有一个可以指定等待时间的变体。
signal()
方法可以唤醒一个等待条件变量的线程,
signalAll()
方法可以唤醒所有等待条件变量的线程。
9.5 原子操作
java.util.concurrent.atomic
包为简单数据类型提供了原子操作,允许无锁访问单个变量。以
AtomicInteger
类为例,它包含以下方法:
boolean compareAndSet (int expect, int update);
int getAndIncrement();
compareAndSet()
方法将变量的值设置为
update
,前提是变量之前的值为
expect
,如果满足条件,返回
true
;否则返回
false
,不执行任何操作。该操作是原子执行的,即执行过程中不会被中断。
getAndIncrement()
方法原子地递增变量的值,并返回变量的前一个值。
AtomicInteger
类还提供了许多类似的方法。
9.6 基于任务的程序执行
java.util.concurrent
包还提供了基于任务的程序执行机制。任务是程序中的一系列操作,可以由任意线程执行。任务的执行由
Executor
接口支持:
public interface Executor {
void execute (Runnable command);
}
command
是通过调用
execute()
方法来执行的任务。
execute()
方法的简单实现可能只是在当前线程中激活
command.run()
方法,更复杂的实现可能会将任务排队,由一组线程中的一个来执行。对于多核处理器,通常有多个线程可用于执行任务,这些线程可以组合成一个线程池,线程池中的每个线程可以执行任意任务。
与为每个任务创建一个单独的线程执行相比,使用任务池通常会减少管理开销,特别是当任务只包含少量操作时。可以使用
Executors
类来组织线程池,该类提供了生成和管理线程池的方法。
综上所述,Java 提供了丰富的线程编程和同步机制,通过
java.util.concurrent
包进一步扩展了这些功能,为开发者在并行编程中提供了更多的选择和灵活性。开发者可以根据具体的应用场景选择合适的同步机制和类,以实现高效、安全的多线程程序。
Java 线程编程全解析
10. 总结与应用建议
在 Java 线程编程中,我们探讨了多种同步机制和编程模式,了解了它们的工作原理和应用场景。为了更好地在实际项目中应用这些知识,下面给出一些总结和应用建议。
10.1 同步机制选择
-
简单同步需求
:如果只是对普通的方法或代码块进行同步,使用
synchronized关键字是最简单直接的方式。它可以方便地实现对对象的互斥访问,避免多个线程同时修改共享资源导致的数据不一致问题。例如,在一个简单的计数器类中,可以使用synchronized方法来确保计数操作的原子性。
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
-
复杂同步场景
:当需要实现更复杂的同步逻辑,如生产者 - 消费者模式、屏障同步等,使用
wait()和notify()以及java.util.concurrent包中的类会更加合适。例如,实现生产者 - 消费者模式时,使用Semaphore或Condition可以更清晰地控制缓冲区的状态,避免竞态条件。 -
原子操作
:对于简单数据类型的无锁访问,使用
java.util.concurrent.atomic包中的原子类,如AtomicInteger,可以提高性能并确保操作的原子性。
10.2 线程优先级使用
虽然 Java 提供了线程优先级的设置功能,但由于没有确切的调度规范,优先级不能替代同步机制。在实际应用中,优先级应仅用于表达不同线程的相对重要性,而不是作为控制线程执行顺序的主要手段。例如,在一个图形界面应用中,可以将处理用户输入的线程设置为较高优先级,以保证界面的响应性。
10.3 避免优先级反转
在使用不同优先级的线程时,要注意避免优先级反转问题。可以通过使用优先级继承来解决这个问题,但要注意 Java 规范并不保证所有 JVM 都支持该机制。在设计多线程程序时,应尽量减少不同优先级线程之间的依赖关系,降低优先级反转的风险。
11. 常见问题与解决方案
在 Java 线程编程中,可能会遇到一些常见的问题,下面介绍这些问题及相应的解决方案。
11.1 死锁问题
死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,导致这些线程都无法继续执行。死锁的产生通常需要满足四个条件:互斥条件、请求和保持条件、不剥夺条件和循环等待条件。
解决方案
:
-
破坏循环等待条件
:可以通过对资源进行排序,让所有线程按照相同的顺序请求资源,避免形成循环等待。例如,在一个需要同时访问两个资源的场景中,规定所有线程都先请求资源 A,再请求资源 B。
-
使用超时机制
:在获取锁时设置超时时间,如果在规定时间内无法获取锁,则放弃并进行其他处理,避免线程长时间等待。例如,使用
tryLock(long time, TimeUnit unit)
方法。
11.2 竞态条件
竞态条件是指多个线程对共享资源进行读写操作时,由于执行顺序的不确定性而导致结果的不确定性。例如,多个线程同时对一个计数器进行递增操作,可能会导致计数器的值不准确。
解决方案
:
-
使用同步机制
:如
synchronized
关键字、
Lock
接口等,确保同一时间只有一个线程可以访问共享资源。
-
使用原子类
:对于简单的数据类型,使用
java.util.concurrent.atomic
包中的原子类可以避免竞态条件。
11.3 线程饥饿
线程饥饿是指一个线程由于优先级较低或其他原因,长时间无法获得执行机会,导致其任务无法完成。
解决方案
:
-
合理设置线程优先级
:避免设置过高或过低的优先级,确保所有线程都有机会执行。
-
使用公平锁
:在使用锁机制时,可以选择公平锁,如
ReentrantLock
的公平模式,保证等待时间最长的线程优先获得锁。
12. 性能优化建议
在多线程编程中,性能优化是一个重要的考虑因素。下面给出一些性能优化的建议。
12.1 减少锁的持有时间
锁的持有时间越长,其他线程等待的时间就越长,会降低程序的并发性能。因此,应尽量减少锁的持有时间,只在必要的代码块中使用锁。例如:
class Example {
private Object lock = new Object();
private int data;
public void updateData() {
// 非关键代码
int temp = calculateTemp();
synchronized (lock) {
// 关键代码,更新共享数据
data = temp;
}
// 非关键代码
processResult();
}
private int calculateTemp() {
// 计算临时数据
return 0;
}
private void processResult() {
// 处理结果
}
}
12.2 使用线程池
为每个任务创建一个新线程会带来较大的开销,特别是在任务数量较多时。使用线程池可以复用线程,减少线程创建和销毁的开销,提高程序的性能。例如:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " is running.");
});
}
executor.shutdown();
}
}
12.3 优化同步机制
根据具体的应用场景选择合适的同步机制,避免使用过于复杂的同步方式。例如,对于简单的同步需求,使用
synchronized
关键字即可;对于复杂的同步场景,使用
java.util.concurrent
包中的类可以提高性能。
13. 总结与展望
Java 线程编程为开发者提供了强大的工具和机制,使得编写高效、安全的多线程程序成为可能。通过本文的介绍,我们了解了 Java 线程编程的基本概念、同步机制、线程调度以及常见问题和解决方案。
随着计算机硬件的发展,多核处理器的普及,多线程编程将变得越来越重要。未来,Java 可能会进一步完善其线程编程的功能,提供更多的工具和类库,以满足开发者在不同场景下的需求。同时,开发者也需要不断学习和掌握多线程编程的技术,提高自己的编程能力,以应对日益复杂的应用场景。
在实际应用中,开发者应根据具体的需求选择合适的同步机制和编程模式,避免常见的问题,优化程序的性能。通过合理地使用 Java 线程编程的特性,可以开发出更加高效、稳定的多线程应用程序。
总结
本文全面介绍了 Java 线程编程的各个方面,包括静态方法同步、等待与通知机制、生产者 - 消费者模式、屏障同步、条件变量、扩展同步模式、线程调度以及
java.util.concurrent
包中的各种同步机制和类。同时,还给出了应用建议、常见问题解决方案和性能优化建议。希望通过本文的介绍,读者能够对 Java 线程编程有更深入的理解,并在实际项目中灵活运用这些知识。
流程图示例
graph TD;
A[开始] --> B[创建线程池];
B --> C[提交任务到线程池];
C --> D{任务是否完成};
D -- 是 --> E[关闭线程池];
D -- 否 --> C;
E --> F[结束];
表格总结
| 同步机制 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| synchronized 关键字 | 简单同步需求 | 简单易用 | 功能相对单一 |
| wait() 和 notify() | 复杂同步场景,如生产者 - 消费者模式 | 灵活实现复杂同步逻辑 | 代码复杂度较高 |
| java.util.concurrent 包 | 复杂同步场景,如屏障同步、原子操作 | 提供丰富的同步类和工具 | 学习成本较高 |
| 原子类 | 简单数据类型的无锁访问 | 无锁操作,性能高 | 仅适用于简单数据类型 |
超级会员免费看
10万+

被折叠的 条评论
为什么被折叠?



