由于同一进程的多个线程共享同一片存储空间,在带来方便的同时,也带来了访问冲突这个严重的问题。Java语言提供了专门机制以解决这种冲突,有效避免了同一个数据对象被多个线程同时访问。
由于我们可以通过 private 关键字来保证数据对象只能被方法访问,所以我们只需针对方法提出一套机制,这套机制就是 synchronized 关键字,它包括两种用法:synchronized 方法和 synchronized 块。
1. synchronized 方法:通过在方法声明中加入 synchronized关键字来声明 synchronized 方法。如:
public synchronized void accessVal(int newVal);
public synchronized void accessVal(int newVal);
synchronized 方法控制对类成员变量的访问:每个类实例对应一把锁,每个 synchronized 方法都必须获得调用该方法的类实例的锁方能执行,否则所属线程阻塞,方法一旦执行,就独占该锁,直到从该方法返回时才将锁释放,此后被阻塞的线程方能获得该锁,重新进入可执行状态。这种机制确保了同一时刻对于每一个类实例,其所有声明为 synchronized 的成员函数中至多只有一个处于可执行状态(因为至多只有一个能够获得该类实例对应的锁),从而有效避免了类成员变量的访问冲突(只要所有可能访问类成员变量的方法均被声明为 synchronized)。
在 Java 中,不光是类实例,每一个类也对应一把锁,这样我们也可将类的静态成员函数声明为 synchronized ,以控制其对类的静态成员变量的访问。
synchronized 方法的缺陷:若将一个大的方法声明为synchronized 将会大大影响效率,典型地,若将线程类的方法 run() 声明为 synchronized ,由于在线程的整个生命期内它一直在运行,因此将导致它对本类任何 synchronized 方法的调用都永远不会成功。当然我们可以通过将访问类成员变量的代码放到专门的方法中,将其声明为 synchronized ,并在主方法中调用来解决这一问题,但是 Java 为我们提供了更好的解决办法,那就是 synchronized 块。
2. synchronized 块:通过 synchronized关键字来声明synchronized 块。语法如下:
synchronized(syncObject) {
//允许访问控制的代码 }
synchronized 块是这样一个代码块,其中的代码必须获得对象 syncObject (如前所述,可以是类实例或类)的锁方能执行,具体机制同前所述。由于可以针对任意代码块,且可任意指定上锁的对象,故灵活性较高。
六:线程的阻塞
为了解决对共享存储区的访问冲突,Java 引入了同步机制,现在让我们来考察多个线程对共享资源的访问,显然同步机制已经不够了,因为在任意时刻所要求的资源不一定已经准备好了被访问,反过来,同一时刻准备好了的资源也可能不止一个。为了解决这种情况下的访问控制问题,Java 引入了对阻塞机制的支持。
阻塞指的是暂停一个线程的执行以等待某个条件发生(如某资源就绪),学过操作系统的同学对它一定已经很熟悉了。Java 提供了大量方法来支持阻塞,下面让我们逐一分析。
1. sleep() 方法:sleep() 允许 指定以毫秒为单位的一段时间作为参数,它使得线程在指定的时间内进入阻塞状态,不能得到CPU 时间,指定的时间一过,线程重新进入可执行状态。
典型地,sleep() 被用在等待某个资源就绪的情形:测试发现条件不满足后,让线程阻塞一段时间后重新测试,直到条件满足为止。
2. suspend() 和 resume() 方法:两个方法配套使用,suspend()使得线程进入阻塞状态,并且不会自动恢复,必须其对应的resume() 被调用,才能使得线程重新进入可执行状态。典型地,suspend() 和 resume() 被用在等待另一个线程产生的结果的情形:测试发现结果还没有产生后,让线程阻塞,另一个线程产生了结果后,调用 resume() 使其恢复。
3. yield() 方法:yield() 使得线程放弃当前分得的 CPU 时间,但是不使线程阻塞,即线程仍处于可执行状态,随时可能再次分得 CPU 时间。调用 yield() 的效果等价于调度程序认为该线程已执行了足够的时间从而转到另一个线程。
4. wait() 和 notify() 方法:两个方法配套使用,wait() 使得线程进入阻塞状态,它有两种形式,一种允许 指定以毫秒为单位的一段时间作为参数,另一种没有参数,前者当对应的 notify() 被调用或者超出指定时间时线程重新进入可执行状态,后者则必须对应的 notify() 被调用。
初看起来它们与 suspend() 和 resume() 方法对没有什么分别,但是事实上它们是截然不同的。区别的核心在于,前面叙述的所有方法,阻塞时都不会释放占用的锁(如果占用了的话),而这一对方法则相反。上述的核心区别导致了一系列的细节上的区别。
首先,前面叙述的所有方法都隶属于 Thread 类,但是这一对却直接隶属于 Object 类,也就是说,所有对象都拥有这一对方法。初看起来这十分不可思议,但是实际上却是很自然的,因为这一对方法阻塞时要释放占用的锁,而锁是任何对象都具有的,调用任意对象的 wait() 方法导致线程阻塞,并且该对象上的锁被释放。而调用 任意对象的notify()方法则导致因调用该对象的 wait() 方法而阻塞的线程中随机选择的一个解除阻塞(但要等到获得锁后才真正可执行)。
其次,前面叙述的所有方法都可在任何位置调用,但是这一对方法却必须在 synchronized 方法或块中调用,理由也很简单,只有在synchronized 方法或块中当前线程才占有锁,才有锁可以释放。同样的道理,调用这一对方法的对象上的锁必须为当前线程所拥有,这样才有锁可以释放。因此,这一对方法调用必须放置在这样的 synchronized 方法或块中,该方法或块的上锁对象就是调用这一对方法的对象。若不满足这一条件,则程序虽然仍能编译,但在运行时会出现IllegalMonitorStateException 异常。
wait() 和 notify() 方法的上述特性决定了它们经常和synchronized 方法或块一起使用,将它们和操作系统的进程间通信机制作一个比较就会发现它们的相似性:synchronized方法或块提供了类似于操作系统原语的功能,它们的执行不会受到多线程机制的干扰,而这一对方法则相当于 block 和wakeup 原语(这一对方法均声明为 synchronized)。它们的结合使得我们可以实现操作系统上一系列精妙的进程间通信的算法(如信号量算法),并用于解决各种复杂的线程间通信问题。
关于 wait() 和 notify() 方法最后再说明两点:
第一:调用 notify() 方法导致解除阻塞的线程是从因调用该对象的 wait() 方法而阻塞的线程中随机选取的,我们无法预料哪一个线程将会被选择,所以编程时要特别小心,避免因这种不确定性而产生问题。
第二:除了 notify(),还有一个方法 notifyAll() 也可起到类似作用,唯一的区别在于,调用 notifyAll() 方法将把因调用该对象的 wait() 方法而阻塞的所有线程一次性全部解除阻塞。当然,只有获得锁的那一个线程才能进入可执行状态。
谈到阻塞,就不能不谈一谈死锁,略一分析就能发现,suspend() 方法和不指定超时期限的 wait() 方法的调用都可能产生死锁。遗憾的是,Java 并不在语言级别上支持死锁的避免,我们在编程中必须小心地避免死锁。
以上我们对 Java 中实现线程阻塞的各种方法作了一番分析,我们重点分析了 wait() 和 notify() 方法,因为它们的功能最强大,使用也最灵活,但是这也导致了它们的效率较低,较容易出错。实际使用中我们应该灵活使用各种方法,以便更好地达到我们的目的。
七:守护线程
守护线程是一类特殊的线程,它和普通线程的区别在于它并不是应用程序的核心部分,当一个应用程序的所有非守护线程终止运行时,即使仍然有守护线程在运行,应用程序也将终止,反之,只要有一个非守护线程在运行,应用程序就不会终止。守护线程一般被用于在后台为其它线程提供服务。
可以通过调用方法 isDaemon() 来判断一个线程是否是守护线程,也可以调用方法 setDaemon() 来将一个线程设为守护线程。
八:线程组
线程组是一个 Java 特有的概念,在 Java 中,线程组是类ThreadGroup 的对象,每个线程都隶属于唯一一个线程组,这个线程组在线程创建时指定并在线程的整个生命期内都不能更改。你可以通过调用包含 ThreadGroup 类型参数的 Thread 类构造函数来指定线程属的线程组,若没有指定,则线程缺省地隶属于名为 system 的系统线程组。
在 Java 中,除了预建的系统线程组外,所有线程组都必须显式创建。在 Java 中,除系统线程组外的每个线程组又隶属于另一个线程组,你可以在创建线程组时指定其所隶属的线程组,若没有指定,则缺省地隶属于系统线程组。这样,所有线程组组成了一棵以系统线程组为根的树。
Java 允许我们对一个线程组中的所有线程同时进行操作,比如我们可以通过调用线程组的相应方法来设置其中所有线程的优先级,也可以启动或阻塞其中的所有线程。
Java 的线程组机制的另一个重要作用是线程安全。线程组机制允许我们通过分组来区分有不同安全特性的线程,对不同组的线程进行不同的处理,还可以通过线程组的分层结构来支持不对等安全措施的采用。Java 的 ThreadGroup 类提供了大量的方法来方便我们对线程组树中的每一个线程组以及线程组中的每一个线程进行操作。
九:总结
在这一讲中,我们一起学习了 Java 多线程编程的方方面面,包括创建线程,以及对多个线程进行调度、管理。我们深刻认识到了多线程编程的复杂性,以及线程切换开销带来的多线程程序的低效性,这也促使我们认真地思考一个问题:我们是否需要多线程?何时需要多线程?
多线程的核心在于多个代码块并发执行,本质特点在于各代码块之间的代码是乱序执行的。我们的程序是否需要多线程,就是要看这是否也是它的内在特点。
假如我们的程序根本不要求多个代码块并发执行,那自然不需要使用多线程;假如我们的程序虽然要求多个代码块并发执行,但是却不要求乱序,则我们完全可以用一个循环来简单高效地实现,也不需要使用多线程;只有当它完全符合多线程的特点时,多线程机制对线程间通信和线程管理的强大支持才能有用武之地,这时使用多线程才是值得的。
线程的(同步)控制
一个Java程序的多线程之间可以共享数据。当线程以异步方式访问共享数据时,有时候是不安全的或者不和逻辑的。比如,同一时刻一个线程在读取数据,另外一个线程在处理数据,当处理数据的线程没有等到读取数据的线程读取完毕就去处理数据,必然得到错误的处理结果。这和我们前面提到的读取数据和处理数据并行多任务并不矛盾,这儿指的是处理数据的线程不能处理当前还没有读取结束的数据,但是可以处理其它的数据。
如果我们采用多线程同步控制机制,等到第一个线程读取完数据,第二个线程才能处理该数据,就会避免错误。可见,线程同步是多线程编程的一个相当重要的技术。
在讲线程的同步控制前我们需要交代如下概念:
1 用Java关键字synchonized同步对共享数据操作的方法
在一个对象中,用synchonized声明的方法为同步方法。Java中有一个同步模型-监视器,负责管理线程对对象中的同步方法的访问,它的原理是:赋予该对象唯一一把'钥匙',当多个线程进入对象,只有取得该对象钥匙的线程才可以访问同步方法,其它线程在该对象中等待,直到该线程用wait()方法放弃这把钥匙,其它等待的线程抢占该钥匙,抢占到钥匙的线程后才可得以执行,而没有取得钥匙的线程仍被阻塞在该对象中等待。
file://声明同步的一种方式:将方法声明同步
class store
{
public synchonized void store_in()
{ …. }
public synchonized void store_out(){ ….}
}
2 利用wait()、notify()及notifyAll()方法发送消息实现线程间的相互联系Java程序中多个线程通过消息来实现互动联系的,这几种方法实现了线程间的消息发送。例如定义一个对象的synchonized 方法,同一时刻只能够有一个线程访问该对象中的同步方法,其它线程被阻塞。通常可以用notify()或notifyAll()方法唤醒其它一个或所有线程。而使用wait()方法来使该线程处于阻塞状态,等待其它的线程用notify()唤醒。
一个实际的例子就是生产和销售,生产单元将产品生产出来放在仓库中,销售单元则从仓库中提走产品,在这个过程中,销售单元必须在仓库中有产品时才能提货;如果仓库中没有产品,则销售单元必须等待。
程序中,假如我们定义一个仓库类store,该类的实例对象就相当于仓库,在store类中定义两个成员方法:store_in(),用来模拟产品制造者往仓库中添加产品;strore_out()方法则用来模拟销售者从仓库中取走产品。然后定义两个线程类:customer类,其中的run()方法通过调用仓库类中的store_out()从仓库中取走产品,模拟销售者;另外一个线程类producer中的run()方法通过调用仓库类中的store_in()方法向仓库添加产品,模拟产品制造者。在主类中创建并启动线程,实现向仓库中添加产品或取走产品。
如果仓库类中的store_in() 和store_out()方法不声明同步,这就是个一般的多线程,我们知道,一个程序中的多线程是交替执行的,运行也是无序的,这样,就可能存在这样的问题:
仓库中没有产品了,销售者还在不断光顾,而且还不停的在'取'产品,这在现实中是不可思义的,在程序中就表现为负值;如果将仓库类中的stroe_in()和store_out()方法声明同步,如上例所示:就控制了同一时刻只能有一个线程访问仓库对象中的同步方法;即一个生产类线程访问被声明为同步的store_in()方法时,其它线程将不能够访问对象中的store_out()同步方法,当然也不能访问store_in()方法。必须等到该线程调用wait()方法放弃钥匙,其它线程才有机会访问同步方法。
这个原理实际中也很好理解,当生产者(producer)取得仓库唯一的钥匙,就向仓库中添放产品,此时其它的销售者(customer,可以是一个或多个)不可能取得钥匙,只有当生产者添放产品结束,交还钥匙并且通知销售者,不同的销售者根据取得钥匙的先后与否决定是否可以进入仓库中提走产品。
轻松使用线程: 不共享有时是最好的
利用 ThreadLocal 提高可伸缩性
ThreadLocal 类是悄悄地出现在 Java 平台版本 1.2 中的。虽然支持线程局部变量早就是许多线程工具(例如 Posix pthreads 工具)的一部分,但 Java Threads API 的最初设计却没有这项有用的功能。而且,最初的实现也相当低效。由于这些原因,ThreadLocal 极少受到关注,但对简化线程安全并发程序的开发来说,它却是很方便的。在轻松使用线程的第 3 部分,Java 软件顾问 Brian Goetz 研究了 ThreadLocal 并提供了一些使用技巧。
参加 Brian 的多线程 Java 编程讨论论坛以获得您工程中的线程和并发问题的帮助。
编写线程安全类是困难的。它不但要求仔细分析在什么条件可以对变量进行读写,而且要求仔细分析其它类能如何使用某个类。有时,要在不影响类的功能、易用性或性能的情况下使类成为线程安全的是很困难的。有些类保留从一个方法调用到下一个方法调用的状态信息,要在实践中使这样的类成为线程安全的是困难的。
管理非线程安全类的使用比试图使类成为线程安全的要更容易些。非线程安全类通常可以安全地在多线程程序中使用,只要您能确保一个线程所用的类的实例不被其它线程使用。例如,JDBC Connection 类是非线程安全的 ― 两个线程不能在小粒度级上安全地共享一个 Connection ― 但如果每个线程都有它自己的 Connection,那么多个线程就可以同时安全地进行数据库操作。
不使用 ThreadLocal 为每个线程维护一个单独的 JDBC 连接(或任何其它对象)当然是可能的;Thread API 给了我们把对象和线程联系起来所需的所有工具。而 ThreadLocal 则使我们能更容易地把线程和它的每线程(per-thread)数据成功地联系起来。
什么是线程局部变量(thread-local variable)?
线程局部变量高效地为每个使用它的线程提供单独的线程局部变量值的副本。每个线程只能看到与自己相联系的值,而不知道别的线程可能正在使用或修改它们自己的副本。一些编译器(例如 Microsoft Visual C++ 编译器或 IBM XL FORTRAN 编译器)用存储类别修饰符(像 static 或 volatile)把对线程局部变量的支持集成到了其语言中。Java 编译器对线程局部变量不提供特别的语言支持;相反地,它用 ThreadLocal 类实现这些支持,核心 Thread 类中有这个类的特别支持。
因为线程局部变量是通过一个类来实现的,而不是作为 Java 语言本身的一部分,所以 Java 语言线程局部变量的使用语法比内建线程局部变量语言的使用语法要笨拙一些。要创建一个线程局部变量,请实例化类 ThreadLocal 的一个对象。 ThreadLocal 类的行为与 java.lang.ref 中的各种 Reference 类的行为很相似;ThreadLocal 类充当存储或检索一个值时的间接句柄。清单 1 显示了 ThreadLocal 接口。
清单 1. ThreadLocal 接口
public class ThreadLocal {
public Object get();
public void set(Object newValue);
public Object initialValue();
}
get() 访问器检索变量的当前线程的值;set() 访问器修改当前线程的值。initialValue() 方法是可选的,如果线程未使用过某个变量,那么您可以用这个方法来设置这个变量的初始值;它允许延迟初始化。用一个示例实现来说明 ThreadLocal 的工作方式是最好的方法。清单 2 显示了 ThreadLocal 的一个实现方式。它不是一个特别好的实现(虽然它与最初实现非常相似),所以很可能性能不佳,但它清楚地说明了 ThreadLocal 的工作方式。
清单 2. ThreadLocal 的糟糕实现
public class ThreadLocal {
private Map values = Collections.synchronizedMap(new HashMap());
public Object get() {
Thread curThread = Thread.currentThread();
Object o = values.get(curThread);
if (o == null && !values.containsKey(curThread)) {
o = initialValue();
values.put(curThread, o);
}
return o;
}
public void set(Object newValue) {
values.put(Thread.currentThread(), newValue);
}
public Object initialValue() {
return null;
}
}
这个实现的性能不会很好,因为每个 get() 和 set() 操作都需要 values 映射表上的同步,而且如果多个线程同时访问同一个 ThreadLocal,那么将发生争用。此外,这个实现也是不切实际的,因为用 Thread 对象做 values 映射表中的关键字将导致无法在线程退出后对 Thread 进行垃圾回收,而且也无法对死线程的 ThreadLocal 的特定于线程的值进行垃圾回收。
用 ThreadLocal 实现每线程 Singleton
线程局部变量常被用来描绘有状态“单子”(Singleton) 或线程安全的共享对象,或者是通过把不安全的整个变量封装进 ThreadLocal,或者是通过把对象的特定于线程的状态封装进 ThreadLocal。例如,在与数据库有紧密联系的应用程序中,程序的很多方法可能都需要访问数据库。在系统的每个方法中都包含一个 Connection 作为参数是不方便的 ― 用“单子”来访问连接可能是一个虽然更粗糙,但却方便得多的技术。然而,多个线程不能安全地共享一个 JDBC Connection。如清单 3 所示,通过使用“单子”中的 ThreadLocal,我们就能让我们的程序中的任何类容易地获取每线程 Connection 的一个引用。这样,我们可以认为 ThreadLocal 允许我们创建每线程单子。
清单 3. 把一个 JDBC 连接存储到一个每线程 Singleton 中
public class ConnectionDispenser {
private static class ThreadLocalConnection extends ThreadLocal {
public Object initialValue() {
return DriverManager.getConnection(ConfigurationSingleton.getDbUrl());
}
}
private ThreadLocalConnection conn = new ThreadLocalConnection();
public static Connection getConnection() {
return (Connection) conn.get();
}
}
任何创建的花费比使用的花费相对昂贵些的有状态或非线程安全的对象,例如 JDBC Connection 或正则表达式匹配器,都是可以使用每线程单子(singleton)技术的好地方。当然,在类似这样的地方,您可以使用其它技术,例如用池,来安全地管理共享访问。然而,从可伸缩性角度看,即使是用池也存在一些潜在缺陷。因为池实现必须使用同步,以维护池数据结构的完整性,如果所有线程使用同一个池,那么在有很多线程频繁地对池进行访问的系统中,程序性能将因争用而降低。
用 ThreadLocal 简化调试日志纪录
其它适合使用 ThreadLocal 但用池却不能成为很好的替代技术的应用程序包括存储或累积每线程上下文信息以备稍后检索之用这样的应用程序。例如,假设您想创建一个用于管理多线程应用程序调试信息的工具。您可以用如清单 4 所示的 DebugLogger 类作为线程局部容器来累积调试信息。在一个工作单元的开头,您清空容器,而当一个错误出现时,您查询该容器以检索这个工作单元迄今为止生成的所有调试信息。
清单 4. 用 ThreadLocal 管理每线程调试日志
public class DebugLogger {
private static class ThreadLocalList extends ThreadLocal {
public Object initialValue() {
return new ArrayList();
}
public List getList() {
return (List) super.get();
}
}
private ThreadLocalList list = new ThreadLocalList();
private static String[] stringArray = new String[0];
public void clear() {
list.getList().clear();
}
public void put(String text) {
list.getList().add(text);
}
public String[] get() {
return list.getList().toArray(stringArray);
}
}
在您的代码中,您可以调用 DebugLogger.put() 来保存您的程序正在做什么的信息,而且,稍后如果有必要(例如发生了一个错误),您能够容易地检索与某个特定线程相关的调试信息。 与简单地把所有信息转储到一个日志文件,然后努力找出哪个日志记录来自哪个线程(还要担心线程争用日志纪录对象)相比,这种技术简便得多,也有效得多。
ThreadLocal 在基于 servlet 的应用程序或工作单元是一个整体请求的任何多线程应用程序服务器中也是很有用的,因为在处理请求的整个过程中将要用到单个线程。您可以通过前面讲述的每线程单子技术用 ThreadLocal 变量来存储各种每请求(per-request)上下文信息。
ThreadLocal 的线程安全性稍差的堂兄弟,InheritableThreadLocal
ThreadLocal 类有一个亲戚,InheritableThreadLocal,它以相似的方式工作,但适用于种类完全不同的应用程序。创建一个线程时如果保存了所有 InheritableThreadLocal 对象的值,那么这些值也将自动传递给子线程。如果一个子线程调用 InheritableThreadLocal 的 get(),那么它将与它的父线程看到同一个对象。为保护线程安全性,您应该只对不可变对象(一旦创建,其状态就永远不会被改变的对象)使用 InheritableThreadLocal,因为对象被多个线程共享。InheritableThreadLocal 很合适用于把数据从父线程传到子线程,例如用户标识(user id)或事务标识(transaction id),但不能是有状态对象,例如 JDBC Connection。
ThreadLocal 的性能
虽然线程局部变量早已赫赫有名并被包括 Posix pthreads 规范在内的很多线程框架支持,但最初的 Java 线程设计中却省略了它,只是在 Java 平台的版本 1.2 中才添加上去。在很多方面,ThreadLocal 仍在发展之中;在版本 1.3 中它被重写,版本 1.4 中又重写了一次,两次都专门是为了性能问题。
在 JDK 1.2 中,ThreadLocal 的实现方式与清单 2 中的方式非常相似,除了用同步 WeakHashMap 代替 HashMap 来存储 values 之外。(以一些额外的性能开销为代价,使用 WeakHashMap 解决了无法对 Thread 对象进行垃圾回收的问题。)不用说,ThreadLocal 的性能是相当差的。
Java 平台版本 1.3 提供的 ThreadLocal 版本已经尽量更好了;它不使用任何同步,从而不存在可伸缩性问题,而且它也不使用弱引用。相反地,人们通过给 Thread 添加一个实例变量(该变量用于保存当前线程的从线程局部变量到它的值的映射的 HashMap)来修改 Thread 类以支持 ThreadLocal。因为检索或设置一个线程局部变量的过程不涉及对可能被另一个线程读写的数据的读写操作,所以您可以不用任何同步就实现 ThreadLocal.get() 和 set()。而且,因为每线程值的引用被存储在自已的 Thread 对象中,所以当对 Thread 进行垃圾回收时,也能对该 Thread 的每线程值进行垃圾回收。
不幸的是,即使有了这些改进,Java 1.3 中的 ThreadLocal 的性能仍然出奇地慢。据我的粗略测量,在双处理器 Linux 系统上的 Sun 1.3 JDK 中进行 ThreadLocal.get() 操作,所耗费的时间大约是无争用同步的两倍。性能这么差的原因是 Thread.currentThread() 方法的花费非常大,占了 ThreadLocal.get() 运行时间的三分之二还多。虽然有这些缺点,JDK 1.3 ThreadLocal.get() 仍然比争用同步快得多,所以如果在任何存在严重争用的地方(可能是有非常多的线程,或者同步块被频繁地执行,或者同步块很大),ThreadLocal 可能仍然要高效得多。
在 Java 平台的最新版本,即版本 1.4b2 中,ThreadLocal 和 Thread.currentThread() 的性能都有了很大提高。有了这些提高,ThreadLocal 应该比其它技术,如用池,更快。由于它比其它技术更简单,也更不易出错,人们最终将发现它是避免线程间出现不希望的交互的有效途径。
ThreadLocal 的好处
ThreadLocal 能带来很多好处。它常常是把有状态类描绘成线程安全的,或者封装非线程安全类以使它们能够在多线程环境中安全地使用的最容易的方式。使用 ThreadLocal 使我们可以绕过为实现线程安全而对何时需要同步进行判断的复杂过程,而且因为它不需要任何同步,所以也改善了可伸缩性。除简单之外,用 ThreadLocal 存储每线程单子或每线程上下文信息在归档方面还有一个颇有价值好处 ― 通过使用 ThreadLocal,存储在 ThreadLocal 中的对象都是不被线程共享的是清晰的,从而简化了判断一个类是否线程安全的工作。
我希望您从这个系列中得到了乐趣,也学到了知识,我也鼓励您到我的讨论论坛中来深入研究多线程问题。
Java多线程学习笔记
一、线程类
Java是通过Java.lang.Thread类来实现多线程的,第个Thread对象描述了一个单独的线程。要产生一个线程,有两种方法:
1、需要从Java.lang.Thread类继承一个新的线程类,重载它的run()方法;
2、通过Runnalbe接口实现一个从非线程类继承来类的多线程,重载Runnalbe接口的run()方法。运行一个新的线程,只需要调用它的start()方法即可。如:
/**=====================================================================
* 文件:ThreadDemo_01.java
* 描述:产生一个新的线程
* ======================================================================
*/
class ThreadDemo extends Thread{
Threads()
{
}
Threads(String szName)
{
super(szName);
}
// 重载run函数
public void run()
{
for (int count = 1,row = 1; row < 20; row++,count++)
{
for (int i = 0; i < count; i++)
{
System.out.print('*');
}
System.out.println();
}
}
}
class ThreadMain{
public static void main(String argv[]){
ThreadDemo th = new ThreadDemo();
// 调用start()方法执行一个新的线程
th.start();
}
}
线程类的一些常用方法:
sleep(): 强迫一个线程睡眠N毫秒。
isAlive(): 判断一个线程是否存活。
join(): 等待线程终止。
activeCount(): 程序中活跃的线程数。
enumerate(): 枚举程序中的线程。
currentThread(): 得到当前线程。
isDaemon(): 一个线程是否为守护线程。
setDaemon(): 设置一个线程为守护线程。(用户线程和守护线程的区别在于,是否等待主线程依赖于主线程结束而结束)
setName(): 为线程设置一个名称。
wait(): 强迫一个线程等待。
notify(): 通知一个线程继续运行。
setPriority(): 设置一个线程的优先级。
二、等待一个线程的结束
有些时候我们需要等待一个线程终止后再运行我们的另一个线程,这时我们应该怎么办呢?请看下面的例子:
/**=====================================================================
* 文件:ThreadDemo_02.java
* 描述:等待一个线程的结束
* ======================================================================
*/
class ThreadDemo extends Thread{
Threads()
{
}
Threads(String szName)
{
super(szName);
}
// 重载run函数
public void run()
{
for (int count = 1,row = 1; row < 20; row++,count++)
{
for (int i = 0; i < count; i++)
{
System.out.print('*');
}
System.out.println();
}
}
}
class ThreadMain{
public static void main(String argv[]){
//产生两个同样的线程
ThreadDemo th1 = new ThreadDemo();
ThreadDemo th2 = new ThreadDemo();
// 我们的目的是先运行第一个线程,再运行第二个线程
th1.start();
th2.start();
}
}
这里我们的目标是要先运行第一个线程,等第一个线程终止后再运行第二个线程,而实际运行的结果是如何的呢?实际上我们运行的结果并不是两个我们想要的直角三角形,而是一些乱七八糟的*号行,有的长,有的短。为什么会这样呢?因为线程并没有按照我们的调用顺序来执行,而是产生了线程赛跑现象。实际上Java并不能按我们的调用顺序来执行线程,这也说明了线程是并行执行的单独代码。如果要想得到我们预期的结果,这里我们就需要判断第一个线程是否已经终止,如果已经终止,再来调用第二个线程。代码如下:
/**=====================================================================
* 文件:ThreadDemo_03.java
* 描述:等待一个线程的结束的两种方法
* ======================================================================
*/
class ThreadDemo extends Thread{
Threads()
{
}
Threads(String szName)
{
super(szName);
}
// 重载run函数
public void run()
{
for (int count = 1,row = 1; row < 20; row++,count++)
{
for (int i = 0; i < count; i++)
{
System.out.print('*');
}
System.out.println();
}
}
}
class ThreadMain{
public static void main(String argv[]){
ThreadMain test = new ThreadMain();
test.Method1();
// test.Method2();
}
// 第一种方法:不断查询第一个线程是否已经终止,如果没有,则让主线程睡眠一直到它终止为止
// 即:while/isAlive/sleep
public void Method1(){
ThreadDemo th1 = new ThreadDemo();
ThreadDemo th2 = new ThreadDemo();
// 执行第一个线程
th1.start();
// 不断查询第一个线程的状态
while(th1.isAlive()){
try{
Thread.sleep(100);
}catch(InterruptedException e){
}
}
//第一个线程终止,运行第二个线程
th2.start();
}
// 第二种方法:join()
public void Method2(){
ThreadDemo th1 = new ThreadDemo();
ThreadDemo th2 = new ThreadDemo();
// 执行第一个线程
th1.start();
try{
th1.join();
}catch(InterruptedException e){
}
// 执行第二个线程
th2.start();
}
三、线程的同步问题
有些时候,我们需要很多个线程共享一段代码,比如一个私有成员或一个类中的静态成员,但是由于线程赛跑的问题,所以我们得到的常常不是正确的输出结果,而相反常常是张冠李戴,与我们预期的结果大不一样。看下面的例子:
/**=============================================================================
* 文件:ThreadDemo_04.java
* 描述:多线程不同步的原因
* =============================================================================
*/
// 共享一个静态数据对象
class ShareData{
public static String szData = "";
}
class ThreadDemo extends Thread{
private ShareData oShare;
ThreadDemo(){
}
ThreadDemo(String szName,ShareData oShare){
super(szName);
this.oShare = oShare;
}
public void run(){
// 为了更清楚地看到不正确的结果,这里放一个大的循环
for (int i = 0; i < 50; i++){
if (this.getName().equals("Thread1")){
oShare.szData = "这是第 1 个线程";
// 为了演示产生的问题,这里设置一次睡眠
try{
Thread.sleep((int)Math.random() * 100);
catch(InterruptedException e){
}
// 输出结果
System.out.println(this.getName() + ":" + oShare.szData);
}else if (this.getName().equals("Thread2")){
oShare.szData = "这是第 1 个线程";
// 为了演示产生的问题,这里设置一次睡眠
try{
Thread.sleep((int)Math.random() * 100);
catch(InterruptedException e){
}
// 输出结果
System.out.println(this.getName() + ":" + oShare.szData);
}
}
}
class ThreadMain{
public static void main(String argv[]){
ShareData oShare = new ShareData();
ThreadDemo th1 = new ThreadDemo("Thread1",oShare);
ThreadDemo th2 = new ThreadDemo("Thread2",oShare);
th1.start();
th2.start();
}
}
由于线程的赛跑问题,所以输出的结果往往是Thread1对应“这是第 2 个线程”,这样与我们要输出的结果是不同的。为了解决这种问题(错误),Java为我们提供了“锁”的机制来实现线程的同步。锁的机制要求每个线程在进入共享代码之前都要取得锁,否则不能进入,而退出共享代码之前则释放该锁,这样就防止了几个或多个线程竞争共享代码的情况,从而解决了线程的不同步的问题。可以这样说,在运行共享代码时则是最多只有一个线程进入,也就是和我们说的垄断。锁机制的实现方法,则是在共享代码之前加入synchronized段,把共享代码包含在synchronized段中。上述问题的解决方法为:
/**=============================================================================
* 文件:ThreadDemo_05.java
* 描述:多线程不同步的解决方法--锁
* =============================================================================
*/
// 共享一个静态数据对象
class ShareData{
public static String szData = "";
}
class ThreadDemo extends Thread{
private ShareData oShare;
ThreadDemo(){
}
ThreadDemo(String szName,ShareData oShare){
super(szName);
this.oShare = oShare;
}
public void run(){
// 为了更清楚地看到不正确的结果,这里放一个大的循环
for (int i = 0; i < 50; i++){
if (this.getName().equals("Thread1")){
// 锁定oShare共享对象
synchronized (oShare){
oShare.szData = "这是第 1 个线程";
// 为了演示产生的问题,这里设置一次睡眠
try{
Thread.sleep((int)Math.random() * 100);
catch(InterruptedException e){
}
// 输出结果
System.out.println(this.getName() + ":" + oShare.szData);
}
}else if (this.getName().equals("Thread2")){
// 锁定共享对象
synchronized (oShare){
oShare.szData = "这是第 1 个线程";
// 为了演示产生的问题,这里设置一次睡眠
try{
Thread.sleep((int)Math.random() * 100);
catch(InterruptedException e){
}
// 输出结果
System.out.println(this.getName() + ":" + oShare.szData);
}
}
}
}
class ThreadMain{
public static void main(String argv[]){
ShareData oShare = new ShareData();
ThreadDemo th1 = new ThreadDemo("Thread1",oShare);
ThreadDemo th2 = new ThreadDemo("Thread2",oShare);
th1.start();
th2.start();
}
}
由于过多的synchronized段将会影响程序的运行效率,因此引入了同步方法,同步方法的实现则是将共享代码单独写在一个方法里,在方法前加上synchronized关键字即可。
在线程同步时的两个需要注意的问题:
1、无同步问题:即由于两个或多个线程在进入共享代码前,得到了不同的锁而都进入共享代码而造成。
2、死锁问题:即由于两个或多个线程都无法得到相应的锁而造成的两个线程都等待的现象。这种现象主要是因为相互嵌套的synchronized代码段而造成,因此,在程序中尽可能少用嵌套的synchronized代码段是防止线程死锁的好方法。
在写上面的代码遇到的一个没能解决的问题,在这里拿出来,希望大家讨论是什么原因。
/**=============================================================================
* 文件:ThreadDemo_06.java
* 描述:为什么造成线程的不同步。
* =============================================================================
*/
class ThreadDemo extends Thread{
//共享一个静态数据成员
private static String szShareData = "";
ThreadDemo(){
}
ThreadDemo(String szName){
super(szName);
}
public void run(){
// 为了更清楚地看到不正确的结果,这里放一个大的循环
for (int i = 0; i < 50; i++){
if (this.getName().equals("Thread1")){
synchronized(szShareData){
szShareData = "这是第 1 个线程";
// 为了演示产生的问题,这里设置一次睡眠
try{
Thread.sleep((int)Math.random() * 100);
catch(InterruptedException e){
}
// 输出结果
System.out.println(this.getName() + ":" + szShareData);
}
}else if (this.getName().equals("Thread2")){
synchronized(szShareData){
szShareData = "这是第 1 个线程";
// 为了演示产生的问题,这里设置一次睡眠
try{
Thread.sleep((int)Math.random() * 100);
catch(InterruptedException e){
}
// 输出结果
System.out.println(this.getName() + ":" + szShareData);
}
}
}
}
class ThreadMain{
public static void main(String argv[]){
ThreadDemo th1 = new ThreadDemo("Thread1");
ThreadDemo th2 = new ThreadDemo("Thread2");
th1.start();
th2.start();
}
}
这段代码的共享成员是一个类中的静态成员,按理说,这里进入共享代码时得到的锁应该是同样的锁,而实际上以上程序的输入却是不同步的,为什么呢??
Java多线程学习笔记(二)
四、Java的等待通知机制
在有些时候,我们需要在几个或多个线程中按照一定的秩序来共享一定的资源。例如生产者--消费者的关系,在这一对关系中实际情况总是先有生产者生产了产品后,消费者才有可能消费;又如在父--子关系中,总是先有父亲,然后才能有儿子。然而在没有引入等待通知机制前,我们得到的情况却常常是错误的。这里我引入《用线程获得强大的功能》一文中的生产者--消费者的例子:
/* ==================================================================================
* 文件:ThreadDemo07.java
* 描述:生产者--消费者
* 注:其中的一些注释是我根据自己的理解加注的
* ==================================================================================
*/
// 共享的数据对象
class ShareData{
private char c;
public void setShareChar(char c){
this.c = c;
}
public char getShareChar(){
return this.c;
}
}
// 生产者线程
class Producer extends Thread{
private ShareData s;
Producer(ShareData s){
this.s = s;
}
public void run(){
for (char ch = 'A'; ch <= 'Z'; ch++){
try{
Thread.sleep((int)Math.random() * 4000);
}catch(InterruptedException e){}
// 生产
s.setShareChar(ch);
System.out.println(ch + " producer by producer.");
}
}
}
// 消费者线程
class Consumer extends Thread{
private ShareData s;
Consumer(ShareData s){
this.s = s;
}
public void run(){
char ch;
do{
try{
Thread.sleep((int)Math.random() * 4000);
}catch(InterruptedException e){}
// 消费
ch = s.getShareChar();
System.out.println(ch + " consumer by consumer.");
}while(ch != 'Z');
}
}
class Test{
public static void main(String argv[]){
ShareData s = new ShareData();
new Consumer(s).start();
new Producer(s).start();
}
}
在以上的程序中,模拟了生产者和消费者的关系,生产者在一个循环中不断生产了从A-Z的共享数据,而消费者则不断地消费生产者生产的A-Z的共享数据。我们开始已经说过,在这一对关系中,必须先有生产者生产,才能有消费者消费。但如果运行我们上面这个程序,结果却出现了在生产者没有生产之前,消费都就已经开始消费了或者是生产者生产了却未能被消费者消费这种反常现象。为了解决这一问题,引入了等待通知(wait/notify)机制如下:
1、在生产者没有生产之前,通知消费者等待;在生产者生产之后,马上通知消费者消费。
2、在消费者消费了之后,通知生产者已经消费完,需要生产。
下面修改以上的例子(源自《用线程获得强大的功能》一文):
/* ==================================================================================
* 文件:ThreadDemo08.java
* 描述:生产者--消费者
* 注:其中的一些注释是我根据自己的理解加注的
* ==================================================================================
*/
class ShareData{
private char c;
// 通知变量
private boolean writeable = true;
// -------------------------------------------------------------------------
// 需要注意的是:在调用wait()方法时,需要把它放到一个同步段里,否则将会出现
// "java.lang.IllegalMonitorStateException: current thread not owner"的异常。
// -------------------------------------------------------------------------
public synchronized void setShareChar(char c){
if (!writeable){
try{
// 未消费等待
wait();
}catch(InterruptedException e){}
}
this.c = c;
// 标记已经生产
writeable = false;
// 通知消费者已经生产,可以消费
notify();
}
public synchronized char getShareChar(){
if (writeable){
try{
// 未生产等待
wait();
}catch(InterruptedException e){}
}
// 标记已经消费
writeable = true;
// 通知需要生产
notify();
return this.c;
}
}
// 生产者线程
class Producer extends Thread{
private ShareData s;
Producer(ShareData s){
this.s = s;
}
public void run(){
for (char ch = 'A'; ch <= 'Z'; ch++){
try{
Thread.sleep((int)Math.random() * 400);
}catch(InterruptedException e){}
s.setShareChar(ch);
System.out.println(ch + " producer by producer.");
}
}
}
// 消费者线程
class Consumer extends Thread{
private ShareData s;
Consumer(ShareData s){
this.s = s;
}
public void run(){
char ch;
do{
try{
Thread.sleep((int)Math.random() * 400);
}catch(InterruptedException e){}
ch = s.getShareChar();
System.out.println(ch + " consumer by consumer.**");
}while (ch != 'Z');
}
}
class Test{
public static void main(String argv[]){
ShareData s = new ShareData();
new Consumer(s).start();
new Producer(s).start();
}
}
在以上程序中,设置了一个通知变量,每次在生产者生产和消费者消费之前,都测试通知变量,检查是否可以生产或消费。最开始设置通知变量为true,表示还未生产,在这时候,消费者需要消费,于时修改了通知变量,调用notify()发出通知。这时由于生产者得到通知,生产出第一个产品,修改通知变量,向消费者发出通知。这时如果生产者想要继续生产,但因为检测到通知变量为false,得知消费者还没有生产,所以调用wait()进入等待状态。因此,最后的结果,是生产者每生产一个,就通知消费者消费一个;消费者每消费一个,就通知生产者生产一个,所以不会出现未生产就消费或生产过剩的情况。
五、线程的中断
在很多时候,我们需要在一个线程中调控另一个线程,这时我们就要用到线程的中断。用最简单的话也许可以说它就相当于播放机中的暂停一样,当第一次按下暂停时,播放器停止播放,再一次按下暂停时,继续从刚才暂停的地方开始重新播放。而在Java中,这个暂停按钮就是Interrupt()方法。在第一次调用interrupt()方法时,线程中断;当再一次调用interrupt()方法时,线程继续运行直到终止。这里依然引用《用线程获得强大功能》一文中的程序片断,但为了更方便看到中断的过程,我在原程序的基础上作了些改进,程序如下:
/* ===================================================================================
* 文件:ThreadDemo09.java
* 描述:线程的中断
* ===================================================================================
*/
class ThreadA extends Thread{
private Thread thdOther;
ThreadA(Thread thdOther){
this.thdOther = thdOther;
}
public void run(){
System.out.println(getName() + " 运行...");
int sleepTime = (int)(Math.random() * 10000);
System.out.println(getName() + " 睡眠 " + sleepTime
+ " 毫秒。");
try{
Thread.sleep(sleepTime);
}catch(InterruptedException e){}
System.out.println(getName() + " 觉醒,即将中断线程 B。");
// 中断线程B,线程B暂停运行
thdOther.interrupt();
}
}
class ThreadB extends Thread{
int count = 0;
public void run(){
System.out.println(getName() + " 运行...");
while (!this.isInterrupted()){
System.out.println(getName() + " 运行中 " + count++);
try{
Thread.sleep(10);
}catch(InterruptedException e){
int sleepTime = (int)(Math.random() * 10000);
System.out.println(getName() + " 睡眠" + sleepTime
+ " 毫秒。觉醒后立即运行直到终止。");
try{
Thread.sleep(sleepTime);
}catch(InterruptedException m){}
System.out.println(getName() + " 已经觉醒,运行终止...");
// 重新设置标记,继续运行
this.interrupt();
}
}
System.out.println(getName() + " 终止。");
}
}
class Test{
public static void main(String argv[]){
ThreadB thdb = new ThreadB();
thdb.setName("ThreadB");
ThreadA thda = new ThreadA(thdb);
thda.setName("ThreadA");
thdb.start();
thda.start();
}
}
运行以上程序,你可以清楚地看到中断的过程。首先线程B开始运行,接着运行线程A,在线程A睡眠一段时间觉醒后,调用interrupt()方法中断线程B,此是可能B正在睡眠,觉醒后掏出一个InterruptedException异常,执行其中的语句,为了更清楚地看到线程的中断恢复,我在InterruptedException异常后增加了一次睡眠,当睡眠结束后,线程B调用自身的interrupt()方法恢复中断,这时测试isInterrupt()返回true,线程退出。
线程和进程(Threads and Processes)
第一个关键的系统级概念,究竟什么是线程或者说究竟什么是进程?她们其实就是操作系统内部的一种数据结构。
进程数据结构掌握着所有与内存相关的东西:全局地址空间、文件句柄等等诸如此类的东西。当一个进程放弃执行(准确的说是放弃占有CPU),而被操作系统交换到硬盘上,使别的进程有机会运行的时候,在那个进程里的所有数据也将被写到硬盘上,甚至包括整个系统的核心(core memory)。可以这么说,当你想到进程(process),就应该想到内存(memory) (进程 == 内存)。如上所述,切换进程的代价非常大,总有那么一大堆的内存要移来移去。你必须用秒这个单位来计量进程切换(上下文切换),对于用户来说秒意味着明显的等待和硬盘灯的狂闪(对于作者的我,就意味着IBM龙腾3代的烂掉,5555555)。言归正传,对于Java而言,JVM就几乎相当于一个进程(process),因为只有进程才能拥有堆内存(heap,也就是我们平时用new操作符,分出来的内存空间)。
那么线程是什么呢?你可以把它看成“一段代码的执行”---- 也就是一系列由JVM执行的二进制指令。这里面没有对象(Object)甚至没有方法(Method)的概念。指令执行的序列可以重叠,并且并行的执行。后面,我会更加详细的论述这个问题。但是请记住,线程是有序的指令,而不是方法(method)。
线程的数据结构,与进程相反,仅仅只包括执行这些指令的信息。它包含当前的运行上下文(context):如寄存器(register)的内容、当前指令的在运行引擎的指令流中的位置、保存方法(methods)本地参数和变量的运行时堆栈。如果发生线程切换,OS只需把寄存器的值压进栈,然后把线程包含的数据结构放到某个类是列表(LIST)的地方;把另一个线程的数据从列表中取出,并且用栈里的值重新设置寄存器。切换线程更加有效率,时间单位是毫秒。对于Java而言,一个线程可以看作是JVM的一个状态。
运行时堆栈(也就是前面说的存储本地变量和参数的地方)是线程数据结构一部分。这是因为多个线程,每一个都有自己的运行时堆栈,也就是说存储在这里面的数据是绝对线程安全(后面将会详细解释这个概念)的。因为可以肯定一个线程是无法修改另一个线程的系统级的数据结构的。也可以这么说一个不访问堆内存的(只读写堆栈内存)方法,是线程安全的(Thread Safe)。
线程安全和同步
线程安全,是指一个方法(method)可以在多线程的环境下安全的有效的访问进程级的数据(这些数据是与其他线程共享的)。事实上,线程安全是个很难达到的目标。
线程安全的核心概念就是同步,它保证多个线程:
同时开始执行,并行运行
不同时访问相同的对象实例
不同时执行同一段代码
我将会在后面的章节,一一细诉这些问题。但现在还是让我们来看看同步的一种经典的
实现方法——信号量。信号量是任何可以让两个线程为了同步它们的操作而相互通信的对象。Java也是通过信号量来实现线程间通信的。
不要被微软的文档所暗示的信号量仅仅是Dijksta提出的计数型信号量所迷惑。信号量其实包含任何可以用来同步的对象。
如果没有synchronized关键字,就无法用JAVA实现信号量,但是仅仅只依靠它也不足够。我将会在后面为大家演示一种用Java实现的信号量。
同步的代价很高哟!
同步(或者说信号量,随你喜欢啦)的一个很让人头痛的问题就是代价。考虑一下,下面的代码:
Listing 1.2:
import java.util.*;
import java.text.NumberFormat;
class Synch
{
private static long[ ] locking_time = new long[100];
private static long[ ] not_locking_time = new long[100];
private static final long ITERATIONS = 10000000;
synchronized long locking (long a, long b){return a + b;}
long not_locking (long a, long b){return a + b;}
private void test( int id )
{
long start = System.currentTimeMillis();
for(long i = ITERATIONS; --i >= 0 ;)
{ locking(i,i); }
locking_time[id] = System.currentTimeMillis() - start;
start = System.currentTimeMillis();
for(long i = ITERATIONS; --i >= 0 ;)
{ not_locking(i,i); }
not_locking_time[id] = System.currentTimeMillis() - start;
}
static void print_results( int id )
{ NumberFormat compositor = NumberFormat.getInstance();
compositor.setMaximumFractionDigits( 2 );
double time_in_synchronization = locking_time[id] - not_locking_time[id];
System.out.println( "Pass " + id + ": Time lost: "
+ compositor.format( time_in_synchronization )
+ " ms. "
+ compositor.format( ((double)locking_time[id]/not_locking_time[id])*100.0 )
+ "% increase" );
}
static public void main(String[ ] args) throws InterruptedException
{
final Synch tester = new Synch();
tester.test(0); print_results(0);
tester.test(1); print_results(1);
tester.test(2); print_results(2);
tester.test(3); print_results(3);
tester.test(4); print_results(4);
tester.test(5); print_results(5);
tester.test(6); print_results(6);
final Object start_gate = new Object();
Thread t1 = new Thread()
{ public void run()
{ try{ synchronized(start_gate) { start_gate.wait(); } }
catch( InterruptedException e ){}
tester.test(7);
}
};
Thread t2 = new Thread()
{ public void run()
{ try{ synchronized(start_gate) { start_gate.wait(); } }
catch( InterruptedException e ){}
tester.test(8);
}
};
Thread.currentThread().setPriority( Thread.MIN_PRIORITY );
t1.start();
t2.start();
synchronized(start_gate){ start_gate.notifyAll(); }
t1.join();
t2.join();
print_results( 7 );
print_results( 8 );
}
}
这是一个简单的基准测试程序,她清楚的向大家揭示了同步的代价是多么的大。test(…)方法调用2个方法1,000,000,0次。其中一个是同步的,另一个则否。下面是在我的机器上输出的结果(CPU: P4 2.4G(B); Memory: 1GB; OS: windows 2000 server(sp3); JDK: Ver1.4.01 and HotSpot 1.4.01-b01):
C:\>java -verbose:gc Synch
Pass 0: Time lost: 251 ms. 727.5% increase
Pass 1: Time lost: 250 ms. 725% increase
Pass 2: Time lost: 251 ms. 602% increase
Pass 3: Time lost: 250 ms. 725% increase
Pass 4: Time lost: 261 ms. 752.5% increase
Pass 5: Time lost: 260 ms. 750% increase
Pass 6: Time lost: 261 ms. 752.5% increase
Pass 7: Time lost: 1,953 ms. 1,248.82% increase
Pass 8: Time lost: 3,475 ms. 8,787.5% increase
这里为了使HotSpot JVM充分的发挥其威力,test( )方法被多次反复调用。一旦这段程序被彻底优化以后,也就是大约在Pass 6时,同步的代价达到最大。Pass 7 和Pass 8与前面的区别在于,我new了两个新的线程来并行执行test方法,两个线程竞争执行(后面是适当的地方,我会解释什么是“竞争”,如果你已经等不及了,买本大学的操作系统课本看看吧! J),这使结果更加接近真实。同步的代价是如此之高的,应该尽量避免无谓的同步代价。
现在是时候我们更深入的讨论一下同步的代价了。HotSpot JVM一般会使用一到两个方法来实现同步,这主要取决于是否存在线程的竞争。当没有竞争的时候,计算机的汇编指令顺序的执行,这些指令的执行是不被打断。指令试图测试一个比特(bit),然后设置各种二进制位来表示测试的结果,如果这个bit没有被设置,指令就设置它。这可以说是非常原始的信号量,因为当两个线程同步的企图设置一个bit的值时,只有一个线程可以成功,两个线程都会检查结果,看看是不是自己设成功了。
如果bit已经被设置(这里说的是有线程竞争的情况下),失败的JVM(线程)不得不离开操作系统的核心进程等待这个bit位被清零。这样来回的在系统核心中切换是非常耗时的。在NT系统下,需要600次机械指令循环来进入一次系统内核,这还仅仅是进入所耗费的时间还不包括做操作的时间。
是不是觉得很无聊了,呵呵!今天似乎都是些不顶用的东西。但这是必须的,为了使你能够读懂后面的内容。下一篇,我将会谈到一些更有趣的话题,例如如何避免同步,如果大家不反对,我还想讲一些设计模式的东西。下回见!
避免同步
大部分显示的同步都可以避免。一般不操作对象状态信息(例如数据成员)的方法都不需要同步,例如:一些方法只访问本地变量(也就是说在方法内部声明的变量),而不操作类级别的数据成员,并且这些方法不会通过传入的引用参数来修改外部的对象。符合这些条件的方法都不需要使用synchronization这种重量级的操作。除此之外,还可以使用一些设计模式(Design Pattern)来避免同步(我将会在后面提到)。
你甚至可以通过适当的组织你的代码来避免同步。相对于同步的一个重要的概念就是原子性。一个原子性的操作事不能被其他线程中断的,通常的原子性操作是不需要同步的。
Java定义一些原子性的操作。一般的给变量付值的操作是原子的,除了long和double。看下面的代码:
class Unreliable
{
private long x;
public long get_x( ) {return x;}
public void set_x(long value) { x = value; }
}
线程1调用:
obj.set_x( 0 );
线程2调用:
obj.set_x( 0x123456789abcdef )
问题在于下面这行代码:
x = value;
JVM为了效率的问题,并没有把x当作一个64位的长整型数来使用,而是把它分为两个32-bit,分别付值:
x.hgh_word = value.high_word;
x.low_word = value.low_word;
因此,存在一个线程设置了高位之后被另一个线程切换出去,而改变了其高位或低位的值。所以,x的值最终可能为0x0123456789abcdef、0x01234567000000、0x00000000abcdef和0x00000000000000。你根本无法确定它的值,唯一的解决方法是,为set_x( )和get_x()方法加上synchronized这个关键字或者把这个付值操作封装在一个确保原子性的代码段里。
所以,在操作的long型数据的时候,千万不要想当然。强迫自己记住吧:只有直接付值操作是原子的(除了上面的例子)。其它,任何表达式,象x = ++y、x += y都是不安全的,不管x或y的数据类型是否是小于64位的。很可能在付值之前,自增之后,被其它线程抢先了(preempted)。
竞争条件
在术语中,对于前面我提到的多线程问题——两个线程同步操作同一个对象,使这个对象的最终状态不明——叫做竞争条件。竞争条件可以在任何应该由程序员保证原子操作的,而又忘记使用synchronized的地方。在这个意义上,可以把synchronized看作一种保证复杂的、顺序一定的操作具有原子性的工具,例如给一个boolean值变量付值,就是一个隐式的同步操作。
不变性
一种有效的语言级的避免同步的方法就是不变性(immutability)。一个自从产生那一刻起就无法再改变的对象就是不变性对象,例如一个String对象。但是要注意类似这样的表达式:string1 += string2;本质上等同于string1 = string1 + string2;其实第三个包含string1和string2的string对象被隐式的产生,最后,把string1的引用指向第三个string。这样的操作,并不是原子的。
由于不变对象的值无法发生改变,所以可以为多个线程安全的同步操作,不需要synchronized。
把一个类的所有数据成员都声明为final就可以创建一个不变类型了。那些被声明为final的数据成员并不是必须在声明的时候就写死,但必须在类的构造函数中,全部明确的初始化。例如:
Class I_am_immutable
{
private final int MAX_VALUE = 10;
priate final int blank_final;
public I_am_immutable( int_initial_value )
{
blank_final = initial_value;
}
}
一个由构造函数进行初始化的final型变量叫做blank final。一般的,如果你频繁的只读访问一个对象,把它声明成一个不变对象是个保证同步的好办法,而且可以提高JVM的效率,因为HotSpot会把它放到堆栈里以供使用。