- 浏览: 35416 次
- 性别:
- 来自: 北京
文章分类
最新评论
刚看到时有一些不理解,后来查了一些资料,对自己有很大帮助,我对synchronized的用法的理解是:
先是synchronized的适用场合,对象,作用以及必要性和副作用
场合:多线程并发访问资源
作用:为资源(比如变量,结构,文件等)加锁
副作用:同步造成延迟等待,没有多线程环境的情况下不要使用,用了这个关键字可以保证安全性,但同时效率就会有所降低。
例子?简单的:
一:多个客户端(jsp?servlet?)访问一个静态全局变量
Object xxx = ...getApplicationObject();
synchronized(xxx){
//更新该变量
}
二:有些容器也会用到,比如Vector和Hashtable就用了synchronized关键字
三:Array(1000)你给他附值,你就用synchronized
因为附值要一定时间,这期间其他不能访问数组
-----------------
synchronized 关键字,它包括两种用法:synchronized 方法和 synchronized 块。
1. synchronized 方法:通过在方法声明中加入 synchronized关键字来声明 synchronized 方法。如:
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 (如前所述,可以是类实例或类)的锁方能执行,具体机制同前所述。由于可以针对任意代码块,且可任意指定上锁的对象,故灵活性较高。
-------------------------
###静态(static)方法synchronized的问题###
在Java中,使用synchronized关键字来进行线程的同步
一个设置了synchronized关键字的方法自动成为同步方法
进入方法则相当于以当前对象为信号量,上锁。退出时解锁。
Java还提供了wait()和notify(),notifyAll()方法辅助进行同步
wait()会解锁当前的信号量,使线程进入堵塞状态。直到使用
同一信号量的notify()或notifyAll()来唤醒它。
线程在不同时刻有不同的状态,正在被执行时,即占有CPU时
我们称之为Running状态。那么其它的状态线程自然是不能运行啦。
很可能有多个线程都不能运行吧,但它们不能运行的原因却是各不相同的。
那么由于相同原因不能运行的线程就会存在一个“池pool”中。
由于虚拟机线程调度而不运行的,处于 Runnable池中,表示万事俱备,
只差CPU,由于使用synchronized而阻塞的线程就会在对象锁
(object's lock pool)池中等待。而同步的方法中调用wait()后,线程则会
在wait池中等待。这里要注意了,首先wait()方法能得到执行说明当前线程
正在运行(在Running状态),从而说明它肯定拥有对象锁;第二,调用
wait()方法进入阻塞状态时,当前线程将释放对象锁(!!)第三,
在notify()或notifyAll()方法唤醒此线程时,它将进入 object's lock pool
池中等待,以重新获得对象锁。状态图如下所示。
Schedule
(Runnable) <-------------> (Running)
^ | \
| | --\
| synchronized wait() --must hav lock
acquire lock | \ release lock
| | \
| | \
| (Blocked in) | (Bolcked in )
----------( object's )<---- ( object's )
( lock pool) <---------- ( wait pool )
notify()
------------------------
对一种特殊的资源——对象中的内存——Java提供了内建的机制来防止它们的冲突。由于我们通常将数据元素设为从属于private(私有)类,然后只通过方法访问那些内存,所以只需将一个特定的方法设为synchronized(同步的),便可有效地防止冲突。在任何时刻,只可有一个线程调用特定对象的一个synchronized方法(尽管那个线程可以调用多个对象的同步方法)。下面列出简单的synchronized方法:
synchronized void f() { /* ... */ }
synchronized void g() { /* ... */ }
每个对象都包含了一把锁(也叫作“监视器”),它自动成为对象的一部分(不必为此写任何特殊的代码)。调用任何synchronized方法时,对象就会被锁定,不可再调用那个对象的其他任何synchronized方法,除非第一个方法完成了自己的工作,并解除锁定。在上面的例子中,如果为一个对象调用f(),便不能再为同样的对象调用g(),除非f()完成并解除锁定。因此,一个特定对象的所有synchronized方法都共享着一把锁,而且这把锁能防止多个方法对通用内存同时进行写操作(比如同时有多个线程)。
每个类也有自己的一把锁(作为类的Class对象的一部分),所以synchronized static方法可在一个类的范围内被相互间锁定起来,防止与static数据的接触。
×××静态方法是对类锁定,普通方法是对对象锁定
-------------
import java.net.*;
import java.io.*;
public class SyncTest extends Thread
{
int whichfunc=0;
public static void main(String [] args)throws Exception
{
SyncTest syn1=new SyncTest(1);
SyncTest syn2=new SyncTest(2);
syn1.join();
syn2.join();
}
public SyncTest(int which)
{
whichfunc=which;
start();
}
public void run()
{
try{
if(whichfunc==1){
func1();
}
else if(whichfunc==2)
{
func2();
}
}
catch(Exception e)
{
e.printStackTrace();
}
}
private static int order=0;
private synchronized static void func1()throws Exception
{
System.out.println("this is func1,value is "+order++);
Thread.sleep(2000);
System.out.println("end of func1,value is "+order);
}
private synchronized static void func2()throws Exception
{
System.out.println("this is func2,value is "+order++);
Thread.sleep(2000);
System.out.println("end of func2,value is "+order);
}
}
对比func1和func2
如果两着都是synchronized static 则两者被同步
如果两者都只是static 而不是synchronized,则不能被同步
如果两者都只是synchronized而不是static 也不能被同步,因为他们不是同一对象的方法。
如果两者都是synchronized,但是有一个不是static 而另一个是,
也不能被同步
所以我认为synchronized作用在static方法前的时候,是对特定的Class对象在全局中同步。
synchronized作用在非static方法前的时候,是对特定的对象同步。
http://www.javaworld.com/javaworld/jw-04-1999/jw-04-toolbox.html
但是非静态变量也可以模拟static方法的同步
例如对于上面两个例子中func1改为
private void func1()throws Exception
{
synchronized(this.getClass())
{
System.out.println("this is func1,value is "+order++);
Thread.sleep(2000);
System.out.println("end of func1,value is "+order);
}
}
效果是一样的。
非static 方法是必须有所依托的对象的,而static方法在语义上是不需要有一个对象。synchronized 有作用域的问题,不是对整个JVM都有效,这一点必须明白。
synchronized 实际上是对一个锁的加琐和释放的问题。对非static方法,synchronized的作用域只是所依托的对象而不是全局唯一的,而 static 方法synchronized作用域是类的Class对象,由于这个对象是全局唯一的,所以static 方法是一个时候只能有一个访问他。在这种意义上我们也可以把static 方法看看成是非static的方法,只不过是一个Class对象的方法。
因此sync同static 是有关系的。
被sync的方法和块在某一时刻只有一个thread在调用他???
当两个对象实例线程调用一个sync的非static方法时候,sync不起任何作用,这是经过理论和实践检验的。
-------------------------------------------------------------------------
编写多线程的Java 应用程序
如何避免当前编程中最常见的问题
Alex Roetter (aroetter@CS.Stanford.edu)
Teton Data Systems 的软件工程师
2001 年 2 月
Java Thread API 允许程序员编写具有多处理机制优点的应用程序,在后台处理任务的同时保持用户所需的交互感。Alex Roetter 介绍了 Java Thread API,并概述多线程可能引起的问题以及常见问题的解决方案
几乎所有使用 AWT 或 Swing 编写的画图程序都需要多线程。但多线程程序会造成许多困难,刚开始编程的开发者常常会发现他们被一些问题所折磨,例如不正确的程序行为或死锁。
在本文中,我们将探讨使用多线程时遇到的问题,并提出那些常见陷阱的解决方案。
线程是什么?
一个程序或进程能够包含多个线程,这些线程可以根据程序的代码执行相应的指令。多线程看上去似乎在并行执行它们各自的工作,就像在一台计算机上运行着多个处理机一样。在多处理机计算机上实现多线程时,它们确实 可以并行工作。和进程不同的是,线程共享地址空间。也就是说,多个线程能够读写相同的变量或数据结构。
编写多线程程序时,你必须注意每个线程是否干扰了其他线程的工作。可以将程序看作一个办公室,如果不需要共享办公室资源或与其他人交流,所有职员就会独立并行地工作。某个职员若要和其他人交谈,当且仅当该职员在“听”且他们两说同样的语言。此外,只有在复印机空闲且处于可用状态(没有仅完成一半的复印工作,没有纸张阻塞等问题)时,职员才能够使用它。在这篇文章中你将看到,在 Java 程序中互相协作的线程就好像是在一个组织良好的机构中工作的职员。
在多线程程序中,线程可以从准备就绪队列中得到,并在可获得的系统 CPU 上运行。操作系统可以将线程从处理器移到准备就绪队列或阻塞队列中,这种情况可以认为是处理器“挂起”了该线程。同样,Java 虚拟机 (JVM) 也可以控制线程的移动——在协作或抢先模型中——从准备就绪队列中将进程移到处理器中,于是该线程就可以开始执行它的程序代码。
协作式线程模型允许线程自己决定什么时候放弃处理器来等待其他的线程。程序开发员可以精确地决定某个线程何时会被其他线程挂起,允许它们与对方有效地合作。缺点在于某些恶意或是写得不好的线程会消耗所有可获得的 CPU 时间,导致其他线程“饥饿”。
在抢占式线程模型中,操作系统可以在任何时候打断线程。通常会在它运行了一段时间(就是所谓的一个时间片)后才打断它。这样的结果自然是没有线程能够不公平地长时间霸占处理器。然而,随时可能打断线程就会给程序开发员带来其他麻烦。同样使用办公室的例子,假设某个职员抢在另一人前使用复印机,但打印工作在未完成的时候离开了,另一人接着使用复印机时,该复印机上可能就还有先前那名职员留下来的资料。抢占式线程模型要求线程正确共享资源,协作式模型却要求线程共享执行时间。由于 JVM 规范并没有特别规定线程模型,Java 开发员必须编写可在两种模型上正确运行的程序。在了解线程以及线程间通讯的一些方面之后,我们可以看到如何为这两种模型设计程序。
线程和 Java 语言
为了使用 Java 语言创建线程,你可以生成一个 Thread 类(或其子类)的对象,并给这个对象发送 start() 消息。(程序可以向任何一个派生自 Runnable 接口的类对象发送 start() 消息。)每个线程动作的定义包含在该线程对象的 run() 方法中。run 方法就相当于传统程序中的 main() 方法;线程会持续运行,直到 run() 返回为止,此时该线程便死了。
上锁
大多数应用程序要求线程互相通信来同步它们的动作。在 Java 程序中最简单实现同步的方法就是上锁。为了防止同时访问共享资源,线程在使用资源的前后可以给该资源上锁和开锁。假想给复印机上锁,任一时刻只有一个职员拥有钥匙。若没有钥匙就不能使用复印机。给共享变量上锁就使得 Java 线程能够快速方便地通信和同步。某个线程若给一个对象上了锁,就可以知道没有其他线程能够访问该对象。即使在抢占式模型中,其他线程也不能够访问此对象,直到上锁的线程被唤醒、完成工作并开锁。那些试图访问一个上锁对象的线程通常会进入睡眠状态,直到上锁的线程开锁。一旦锁被打开,这些睡眠进程就会被唤醒并移到准备就绪队列中。
在 Java 编程中,所有的对象都有锁。线程可以使用 synchronized 关键字来获得锁。在任一时刻对于给定的类的实例,方法或同步的代码块只能被一个线程执行。这是因为代码在执行之前要求获得对象的锁。继续我们关于复印机的比喻,为了避免复印冲突,我们可以简单地对复印资源实行同步。如同下列的代码例子,任一时刻只允许一位职员使用复印资源。通过使用方法(在 Copier 对象中)来修改复印机状态。这个方法就是同步方法。只有一个线程能够执行一个 Copier 对象中同步代码,因此那些需要使用 Copier 对象的职员就必须排队等候。
class CopyMachine {
public synchronized void makeCopies(Document d, int nCopies) {
//only one thread executes this at a time
}
public void loadPaper() {
//multiple threads could access this at once!
synchronized(this) {
//only one thread accesses this at a time
//feel free to use shared resources, overwrite members, etc.
}
}
}
Fine-grain 锁
在对象级使用锁通常是一种比较粗糙的方法。为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。由于每个对象都有锁,可以如下所示使用虚拟对象来上锁:
class FineGrainLock {
MyMemberClass x, y;
Object xlock = new Object(), ylock = new Object();
public void foo() {
synchronized(xlock) {
//access x here
}
//do something here - but don't use shared resources
synchronized(ylock) {
//access y here
}
}
public void bar() {
synchronized(this) {
//access both x and y here
}
//do something here - but don't use shared resources
}
}
若为了在方法级上同步,不能将整个方法声明为 synchronized 关键字。它们使用的是成员锁,而不是 synchronized 方法能够获得的对象级锁。
信号量
通常情况下,可能有多个线程需要访问数目很少的资源。假想在服务器上运行着若干个回答客户端请求的线程。这些线程需要连接到同一数据库,但任一时刻只能获得一定数目的数据库连接。你要怎样才能够有效地将这些固定数目的数据库连接分配给大量的线程?一种控制访问一组资源的方法(除了简单地上锁之外),就是使用众所周知的信号量计数 (counting semaphore)。信号量计数将一组可获得资源的管理封装起来。信号量是在简单上锁的基础上实现的,相当于能令线程安全执行,并初始化为可用资源个数的计数器。例如我们可以将一个信号量初始化为可获得的数据库连接个数。一旦某个线程获得了信号量,可获得的数据库连接数减一。线程消耗完资源并释放该资源时,计数器就会加一。当信号量控制的所有资源都已被占用时,若有线程试图访问此信号量,则会进入阻塞状态,直到有可用资源被释放。
信号量最常见的用法是解决“消费者-生产者问题”。当一个线程进行工作时,若另外一个线程访问同一共享变量,就可能产生此问题。消费者线程只能在生产者线程完成生产后才能够访问数据。使用信号量来解决这个问题,就需要创建一个初始化为零的信号量,从而让消费者线程访问此信号量时发生阻塞。每当完成单位工作时,生产者线程就会向该信号量发信号(释放资源)。每当消费者线程消费了单位生产结果并需要新的数据单元时,它就会试图再次获取信号量。因此信号量的值就总是等于生产完毕可供消费的数据单元数。这种方法比采用消费者线程不停检查是否有可用数据单元的方法要高效得多。因为消费者线程醒来后,倘若没有找到可用的数据单元,就会再度进入睡眠状态,这样的操作系统开销是非常昂贵的。
尽管信号量并未直接被 Java 语言所支持,却很容易在给对象上锁的基础上实现。一个简单的实现方法如下所示:
class Semaphore {
private int count;
public Semaphore(int n) {
this.count = n;
}
public synchronized void acquire() {
while(count == 0) {
try {
wait();
} catch (InterruptedException e) {
//keep trying
}
}
count--;
}
public synchronized void release() {
count++;
notify(); //alert a thread that's blocking on this semaphore
}
}
常见的上锁问题
不幸的是,使用上锁会带来其他问题。让我们来看一些常见问题以及相应的解决方法:
死锁。死锁是一个经典的多线程问题,因为不同的线程都在等待那些根本不可能被释放的锁,从而导致所有的工作都无法完成。假设有两个线程,分别代表两个饥饿的人,他们必须共享刀叉并轮流吃饭。他们都需要获得两个锁:共享刀和共享叉的锁。假如线程 "A" 获得了刀,而线程 "B" 获得了叉。线程 A 就会进入阻塞状态来等待获得叉,而线程 B 则阻塞来等待 A 所拥有的刀。这只是人为设计的例子,但尽管在运行时很难探测到,这类情况却时常发生。虽然要探测或推敲各种情况是非常困难的,但只要按照下面几条规则去设计系统,就能够避免死锁问题:
让所有的线程按照同样的顺序获得一组锁。这种方法消除了 X 和 Y 的拥有者分别等待对方的资源的问题。
将多个锁组成一组并放到同一个锁下。前面死锁的例子中,可以创建一个银器对象的锁。于是在获得刀或叉之前都必须获得这个银器的锁。
将那些不会阻塞的可获得资源用变量标志出来。当某个线程获得银器对象的锁时,就可以通过检查变量来判断是否整个银器集合中的对象锁都可获得。如果是,它就可以获得相关的锁,否则,就要释放掉银器这个锁并稍后再尝试。
最重要的是,在编写代码前认真仔细地设计整个系统。多线程是困难的,在开始编程之前详细设计系统能够帮助你避免难以发现死锁的问题。
Volatile 变量. volatile 关键字是 Java 语言为优化编译器设计的。以下面的代码为例:
class VolatileTest {
public void foo() {
boolean flag = false;
if(flag) {
//this could happen
}
}
}
一个优化的编译器可能会判断出 if 部分的语句永远不会被执行,就根本不会编译这部分的代码。如果这个类被多线程访问, flag 被前面某个线程设置之后,在它被 if 语句测试之前,可以被其他线程重新设置。用 volatile 关键字来声明变量,就可以告诉编译器在编译的时候,不需要通过预测变量值来优化这部分的代码。
无法访问的线程 有时候虽然获取对象锁没有问题,线程依然有可能进入阻塞状态。在 Java 编程中 IO 就是这类问题最好的例子。当线程因为对象内的 IO 调用而阻塞时,此对象应当仍能被其他线程访问。该对象通常有责任取消这个阻塞的 IO 操作。造成阻塞调用的线程常常会令同步任务失败。如果该对象的其他方法也是同步的,当线程被阻塞时,此对象也就相当于被冷冻住了。其他的线程由于不能获得对象的锁,就不能给此对象发消息(例如,取消 IO 操作)。必须确保不在同步代码中包含那些阻塞调用,或确认在一个用同步阻塞代码的对象中存在非同步方法。尽管这种方法需要花费一些注意力来保证结果代码安全运行,但它允许在拥有对象的线程发生阻塞后,该对象仍能够响应其他线程。
为不同的线程模型进行设计
判断是抢占式还是协作式的线程模型,取决于虚拟机的实现者,并根据各种实现而不同。因此,Java 开发员必须编写那些能够在两种模型上工作的程序。
正如前面所提到的,在抢占式模型中线程可以在代码的任何一个部分的中间被打断,除非那是一个原子操作代码块。原子操作代码块中的代码段一旦开始执行,就要在该线程被换出处理器之前执行完毕。在 Java 编程中,分配一个小于 32 位的变量空间是一种原子操作,而此外象 double 和 long 这两个 64 位数据类型的分配就不是原子的。使用锁来正确同步共享资源的访问,就足以保证一个多线程程序在抢占式模型下正确工作。
而在协作式模型中,是否能保证线程正常放弃处理器,不掠夺其他线程的执行时间,则完全取决于程序员。调用 yield() 方法能够将当前的线程从处理器中移出到准备就绪队列中。另一个方法则是调用 sleep() 方法,使线程放弃处理器,并且在 sleep 方法中指定的时间间隔内睡眠。
正如你所想的那样,将这些方法随意放在代码的某个地方,并不能够保证正常工作。如果线程正拥有一个锁(因为它在一个同步方法或代码块中),则当它调用 yield() 时不能够释放这个锁。这就意味着即使这个线程已经被挂起,等待这个锁释放的其他线程依然不能继续运行。为了缓解这个问题,最好不在同步方法中调用 yield 方法。将那些需要同步的代码包在一个同步块中,里面不含有非同步的方法,并且在这些同步代码块之外才调用 yield。
另外一个解决方法则是调用 wait() 方法,使处理器放弃它当前拥有的对象的锁。如果对象在方法级别上使同步的,这种方法能够很好的工作。因为它仅仅使用了一个锁。如果它使用 fine-grained 锁,则 wait() 将无法放弃这些锁。此外,一个因为调用 wait() 方法而阻塞的线程,只有当其他线程调用 notifyAll() 时才会被唤醒。
线程和 AWT/Swing
在那些使用 Swing 和/或 AWT 包创建 GUI (用户图形界面)的 Java 程序中,AWT 事件句柄在它自己的线程中运行。开发员必须注意避免将这些 GUI 线程与较耗时间的计算工作绑在一起,因为这些线程必须负责处理用户时间并重绘用户图形界面。换句话来说,一旦 GUI 线程处于繁忙,整个程序看起来就象无响应状态。Swing 线程通过调用合适方法,通知那些 Swing callback (例如 Mouse Listener 和 Action Listener )。 这种方法意味着 listener 无论要做多少事情,都应当利用 listener callback 方法产生其他线程来完成此项工作。目的便在于让 listener callback 更快速返回,从而允许 Swing 线程响应其他事件。
如果一个 Swing 线程不能够同步运行、响应事件并重绘输出,那怎么能够让其他的线程安全地修改 Swing 的状态?正如上面提到的,Swing callback 在 Swing 线程中运行。因此他们能修改 Swing 数据并绘到屏幕上。
但是如果不是 Swing callback 产生的变化该怎么办呢?使用一个非 Swing 线程来修改 Swing 数据是不安全的。Swing 提供了两个方法来解决这个问题:invokeLater() 和 invokeAndWait()。为了修改 Swing 状态,只要简单地调用其中一个方法,让 Runnable 的对象来做这些工作。因为 Runnable 对象通常就是它们自身的线程,你可能会认为这些对象会作为线程来执行。但那样做其实也是不安全的。事实上,Swing 会将这些对象放到队列中,并在将来某个时刻执行它的 run 方法。这样才能够安全修改 Swing 状态。
总结
Java 语言的设计,使得多线程对几乎所有的 Applet 都是必要的。特别是,IO 和 GUI 编程都需要多线程来为用户提供完美的体验。如果依照本文所提到的若干基本规则,并在开始编程前仔细设计系统——包括它对共享资源的访问等,你就可以避免许多常见和难以发觉的线程陷阱。
资料
参考 Java 2 平台上的 API 规范说明书(1.3 版标准):Java 2 API 文档.
更多关于 JVM 对线程和上锁处理的信息,可以参阅 Java 虚拟机规范说明书.
Allen Holub 的 Taming Java Threads (APress, June 2000) 是一本极好的参考书
你可能还希望阅读 Allen 的文章 如果我是国王:关于解决 Java 编程语言线程问题的建议 (developerWorks, October 2000), 里面阐述了一些被他称为“一门伟大语言最虚弱之处”的问题。
关于作者
Alex Roetter 已经有数年关于用 Java 以及其他编程语言编写多线程应用程序的经验,在斯坦福大学获得了计算机科学学士学位。你可以通过 aroetter@CS.Stanford.edu 与 Alex 联系。
==========================
轻松使用线程: 同步不是敌人
我们什么时候需要同步,而同步的代价到底有多大?
Brian Goetz (brian@quiotix.com)
软件顾问,Quiotix
2001 年 7 月
与许多其它的编程语言不同,Java 语言规范包括对线程和并发的明确支持。语言本身支持并发,这使得指定和管理共享数据的约束以及跨线程操作的计时变得更简单,但是这没有使得并发编程的复杂性更易于理解。这个三部分的系列文章的目的在于帮助程序员理解用 Java 语言进行多线程编程的一些主要问题,特别是线程安全对 Java 程序性能的影响。
请点击文章顶部或底部的讨论进入由 Brian Goetz 主持的 “Java 线程:技巧、窍门和技术”讨论论坛,与本文作者和其他读者交流您对本文或整个多线程的想法。注意该论坛讨论的是使用多线程时遇到的所有问题,而并不限于本文的内容。
大多数编程语言的语言规范都不会谈到线程和并发的问题;因为一直以来,这些问题都是留给平台或操作系统去详细说明的。但是,Java 语言规范(JLS)却明确包括一个线程模型,并提供了一些语言元素供开发人员使用以保证他们程序的线程安全。
对线程的明确支持有利也有弊。它使得我们在写程序时更容易利用线程的功能和便利,但同时也意味着我们不得不注意所写类的线程安全,因为任何类都很有可能被用在一个多线程的环境内。
许多用户第一次发现他们不得不去理解线程的概念的时候,并不是因为他们在写创建和管理线程的程序,而是因为他们正在用一个本身是多线程的工具或框架。任何用过 Swing GUI 框架或写过小服务程序或 JSP 页的开发人员(不管有没有意识到)都曾经被线程的复杂性困扰过。
Java 设计师是想创建一种语言,使之能够很好地运行在现代的硬件,包括多处理器系统上。要达到这一目的,管理线程间协调的工作主要推给了软件开发人员;程序员必须指定线程间共享数据的位置。在 Java 程序中,用来管理线程间协调工作的主要工具是 synchronized 关键字。在缺少同步的情况下,JVM 可以很自由地对不同线程内执行的操作进行计时和排序。在大部分情况下,这正是我们想要的,因为这样可以提高性能,但它也给程序员带来了额外的负担,他们不得不自己识别什么时候这种性能的提高会危及程序的正确性。
synchronized 真正意味着什么?
大部分 Java 程序员对同步的块或方法的理解是完全根据使用互斥(互斥信号量)或定义一个临界段(一个必须原子性地执行的代码块)。虽然 synchronized 的语义中确实包括互斥和原子性,但在管程进入之前和在管程退出之后发生的事情要复杂得多。
synchronized 的语义确实保证了一次只有一个线程可以访问被保护的区段,但同时还包括同步线程在主存内互相作用的规则。理解 Java 内存模型(JMM)的一个好方法就是把各个线程想像成运行在相互分离的处理器上,所有的处理器存取同一块主存空间,每个处理器有自己的缓存,但这些缓存可能并不总和主存同步。在缺少同步的情况下,JMM 会允许两个线程在同一个内存地址上看到不同的值。而当用一个管程(锁)进行同步的时候,一旦申请加了锁,JMM 就会马上要求该缓存失效,然后在它被释放前对它进行刷新(把修改过的内存位置写回主存)。不难看出为什么同步会对程序的性能影响这么大;频繁地刷新缓存代价会很大。
使用一条好的运行路线
如果同步不适当,后果是很严重的:会造成数据混乱和争用情况,导致程序崩溃,产生不正确的结果,或者是不可预计的运行。更糟的是,这些情况可能很少发生且具有偶然性(使得问题很难被监测和重现)。如果测试环境和开发环境有很大的不同,无论是配置的不同,还是负荷的不同,都有可能使得这些问题在测试环境中根本不出现,从而得出错误的结论:我们的程序是正确的,而事实上这些问题只是还没出现而已。
争用情况定义
争用情况是一种特定的情况:两个或更多的线程或进程读或写一些共享数据,而最终结果取决于这些线程是如何被调度计时的。争用情况可能会导致不可预见的结果和隐蔽的程序错误。
另一方面,不当或过度地使用同步会导致其它问题,比如性能很差和死锁。当然,性能差虽然不如数据混乱那么严重,但也是一个严重的问题,因此同样不可忽视。编写优秀的多线程程序需要使用好的运行路线,足够的同步可以使您的数据不发生混乱,但不需要滥用到去承担死锁或不必要地削弱程序性能的风险。
同步的代价有多大?
由于包括缓存刷新和设置失效的过程,Java 语言中的同步块通常比许多平台提供的临界段设备代价更大,这些临界段通常是用一个原子性的“test and set bit”机器指令实现的。即使一个程序只包括一个在单一处理器上运行的单线程,一个同步的方法调用仍要比非同步的方法调用慢。如果同步时还发生锁定争用,那么性能上付出的代价会大得多,因为会需要几个线程切换和系统调用。
幸运的是,随着每一版的 JVM 的不断改进,既提高了 Java 程序的总体性能,同时也相对减少了同步的代价,并且将来还可能会有进一步的改进。此外,同步的性能代价经常是被夸大的。一个著名的资料来源就曾经引证说一个同步的方法调用比一个非同步的方法调用慢 50 倍。虽然这句话有可能是真的,但也会产生误导,而且已经导致了许多开发人员即使在需要的时候也避免使用同步。
严格依照百分比计算同步的性能损失并没有多大意义,因为一个无争用的同步给一个块或方法带来的是固定的性能损失。而这一固定的延迟带来的性能损失百分比取决于在该同步块内做了多少工作。对一个 空方法的同步调用可能要比对一个空方法的非同步调用慢 20 倍,但我们多长时间才调用一次空方法呢?当我们用更有代表性的小方法来衡量同步损失时,百分数很快就下降到可以容忍的范围之内。
表 1 把一些这种数据放在一起来看。它列举了一些不同的实例,不同的平台和不同的 JVM 下一个同步的方法调用相对于一个非同步的方法调用的损失。在每一个实例下,我运行一个简单的程序,测定循环调用一个方法 10,000,000 次所需的运行时间,我调用了同步和非同步两个版本,并比较了结果。表格中的数据是同步版本的运行时间相对于非同步版本的运行时间的比率;它显示了同步的性能损失。每次运行调用的都是清单 1 中的简单方法之一。
表格 1 中显示了同步方法调用相对于非同步方法调用的相对性能;为了用绝对的标准测定性能损失,必须考虑到 JVM 速度提高的因素,这并没有在数据中体现出来。在大多数测试中,每个 JVM 的更高版本都会使 JVM 的总体性能得到很大提高,很有可能 1.4 版的 Java 虚拟机发行的时候,它的性能还会有进一步的提高。
表 1. 无争用同步的性能损失
JDK staticEmpty empty fetch hashmapGet singleton create
Linux / JDK 1.1 9.2 2.4 2.5 n/a 2.0 1.42
Linux / IBM Java SDK 1.1 33.9 18.4 14.1 n/a 6.9 1.2
Linux / JDK 1.2 2.5 2.2 2.2 1.64 2.2 1.4
Linux / JDK 1.3 (no JIT) 2.52 2.58 2.02 1.44 1.4 1.1
Linux / JDK 1.3 -server 28.9 21.0 39.0 1.87 9.0 2.3
Linux / JDK 1.3 -client 21.2 4.2 4.3 1.7 5.2 2.1
Linux / IBM Java SDK 1.3 8.2 33.4 33.4 1.7 20.7 35.3
Linux / gcj 3.0 2.1 3.6 3.3 1.2 2.4 2.1
Solaris / JDK 1.1 38.6 20.1 12.8 n/a 11.8 2.1
Solaris / JDK 1.2 39.2 8.6 5.0 1.4 3.1 3.1
Solaris / JDK 1.3 (no JIT) 2.0 1.8 1.8 1.0 1.2 1.1
Solaris / JDK 1.3 -client 19.8 1.5 1.1 1.3 2.1 1.7
Solaris / JDK 1.3 -server 1.8 2.3 53.0 1.3 4.2 3.2
清单 1. 基准测试中用到的简单方法
public static void staticEmpty() { }
public void empty() { }
public Object fetch() { return field; }
public Object singleton() {
if (singletonField == null)
singletonField = new Object();
return singletonField;
}
public Object hashmapGet() {
return hashMap.get("this");
}
public Object create() {
return new Object();
}
这些小基准测试也阐明了存在动态编译器的情况下解释性能结果所面临的挑战。对于 1.3 JDK 在有和没有 JIT 时,数字上的巨大差异需要给出一些解释。对那些非常简单的方法(empty 和 fetch),基准测试的本质(它只是执行一个几乎什么也不做的紧凑的循环)使得 JIT 可以动态地编译整个循环,把运行时间压缩到几乎没有的地步。但在一个实际的程序中,JIT 能否这样做就要取决于很多因素了,所以,无 JIT 的计时数据可能在做公平对比时更有用一些。在任何情况下,对于更充实的方法( create 和 hashmapGet),JIT 就不能象对更简单些的方法那样使非同步的情况得到巨大的改进。另外,从数据中看不出 JVM 是否能够对测试的重要部分进行优化。同样,在可比较的 IBM 和 Sun JDK 之间的差异反映了 IBM Java SDK 可以更大程度地优化非同步的循环,而不是同步版本代价更高。这在纯计时数据中可以明显地看出(这里不提供)。
从这些数字中我们可以得出以下结论:对非争用同步而言,虽然存在性能损失,但在运行许多不是特别微小的方法时,损失可以降到一个合理的水平;大多数情况下损失大概在 10% 到 200% 之间(这是一个相对较小的数目)。所以,虽然同步每个方法是不明智的(这也会增加死锁的可能性),但我们也不需要这么害怕同步。这里使用的简单测试是说明一个无争用同步的代价要比创建一个对象或查找一个 HashMap 的代价小。
由于早期的书籍和文章暗示了无争用同步要付出巨大的性能代价,许多程序员就竭尽全力避免同步。这种恐惧导致了许多有问题的技术出现,比如说 double-checked locking(DCL)。许多关于 Java 编程的书和文章都推荐 DCL,它看上去真是避免不必要的同步的一种聪明的方法,但实际上它根本没有用,应该避免使用它。DCL 无效的原因很复杂,已超出了本文讨论的范围(要深入了解,请参阅参考资料里的链接)。
不要争用
假设同步使用正确,若线程真正参与争用加锁,您也能感受到同步对实际性能的影响。并且无争用同步和争用同步间的性能损失差别很大;一个简单的测试程序指出争用同步比无争用同步慢 50 倍。把这一事实和我们上面抽取的观察数据结合在一起,可以看出使用一个争用同步的代价至少相当于创建 50 个对象。
所以,在调试应用程序中同步的使用时,我们应该努力减少实际争用的数目,而根本不是简单地试图避免使用同步。这个系列的第 2 部分将把重点放在减少争用的技术上,包括减小锁的粒度、减小同步块的大小以及减小线程间共享数据的数量。
什么时候需要同步?
要使您的程序线程安全,首先必须确定哪些数据将在线程间共享。如果正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。有些程序员可能会惊讶地发现,这些规则在简单地检查一个共享引用是否非空的时候也用得上。
许多人会发现这些定义惊人地严格。有一种普遍的观点是,如果只是要读一个对象的字段,不需要请求加锁,尤其是在 JLS 保证了 32 位读操作的原子性的情况下,它更是如此。但不幸的是,这个观点是错误的。除非所指的字段被声明为 volatile,否则 JMM 不会要求下面的平台提供处理器间的缓存一致性和顺序连贯性,所以很有可能,在某些平台上,没有同步就会读到陈旧的数据。有关更详细的信息,请参阅参考资料。
在确定了要共享的数据之后,还要确定要如何保护那些数据。在简单情况下,只需把它们声明为 volatile 即可保护数据字段;在其它情况下,必须在读或写共享数据前请求加锁,一个很好的经验是明确指出使用什么锁来保护给定的字段或对象,并在你的代码里把它记录下来。
还有一点值得注意的是,简单地同步存取器方法(或声明下层的字段为 volatile)可能并不足以保护一个共享字段。可以考虑下面的示例:
...
private int foo;
public synchronized int getFoo() { return foo; }
public synchronized void setFoo(int f) { foo = f; }
如果一个调用者想要增加 foo 属性值,以下完成该功能的代码就不是线程安全的:
...
setFoo(getFoo() + 1);
如果两个线程试图同时增加 foo 属性值,结果可能是 foo 的值增加了 1 或 2,这由计时决定。调用者将需要同步一个锁,才能防止这种争用情况;一个好方法是在 JavaDoc 类中指定同步哪个锁,这样类的调用者就不需要自己猜了。
以上情况是一个很好的示例,说明我们应该注意多层次粒度的数据完整性;同步存取器方法确保调用者能够存取到一致的和最近版本的属性值,但如果希望属性的将来值与当前值一致,或多个属性间相互一致,我们就必须同步复合操作 — 可能是在一个粗粒度的锁上。
如果情况不确定,考虑使用同步包装
有时,在写一个类的时候,我们并不知道它是否要用在一个共享环境里。我们希望我们的类是线程安全的,但我们又不希望给一个总是在单线程环境内使用的类加上同步的负担,而且我们可能也不知道使用这个类时合适的锁粒度是多大。幸运的是,通过提供同步包装,我们可以同时达到以上两个目的。Collections 类就是这种技术的一个很好的示例;它们是非同步的,但在框架中定义的每个接口都有一个同步包装(例如, Collections.synchronizedMap()),它用一个同步的版本来包装每个方法。
结论
虽然 JLS 给了我们可以使我们的程序线程安全的工具,但线程安全也不是天上掉下来的馅饼。使用同步会蒙受性能损失,而同步使用不当又会使我们承担数据混乱、结果不一致或死锁的风险。幸运的是,在过去的几年内 JVM 有了很大的改进,大大减少了与正确使用同步相关的性能损失。通过仔细分析在线程间如何共享数据,适当地同步对共享数据的操作,可以使得您的程序既是线程安全的,又不会承受过多的性能负担。
参考资料
请点击文章顶部或底部的讨论进入由 Brian Goetz 主持的,关于“Java 线程:技巧、窍门和技术”的 讨论论坛。
Jack Shirazi 编写的 Java Performance Tuning(O'Reilly & Associates, 2000)可以为在 Java 平台上解决性能问题提供指导。本书引用的与本书一起提供的参考资料提供了很好的性能调试技巧。
Dov Bulka 的 Java Performance and Scalability,第 1 卷:Server-Side Programming Techniques(Addison-Wesley,2000)提供了大量的设计技巧和诀窍,可帮助您增强自己的应用程序的性能。
Steve Wilson 和 Jeff Kesselman 的 Java Platform Performance: Strategies and Tactics(Addison-Wesley,2000)为有经验的 Java 程序员提供了生成快速、有效的 Java 代码的技术。
Brian Goetz 最近的著作“Double-checked locking: Clever, but broken”(JavaWorld,2001 年 2 月)详细探索了 JMM 并描述了特定情况下不使用同步的惊人后果。
公认的多线程权威 Allen Holub 在他的文章“警告:多处理器世界中的线程”(JavaWorld,2001 年 2 月)中揭示了为什么用于减少同步负担的大多数技巧都不起作用。
Peter Haggar 描述了怎样用固定的,循环的顺序获取多个锁定以避免死锁(developerWorks,2000 年 9 月)。
在他的文章“编写多线程 Java 应用”(developerWorks,2001 年 9 月)中,Alex Roetter 介绍了 Java Thread API,概括了多线程涉及的问题,并为一般性的问题提供了解决方案。
Doug Lea 的 Concurrent Programming in Java,第 2 版(Addison-Wesley,1999)是关于 Java 语言中多线程编程的敏感问题的权威书籍。
“同步和 Java 内存模型”摘录自 Doug Lea 的关于 synchronized 的实际意义的著作。
Bill Pugh 的 Java 内存模型为您学习 JMM 提供了一个很好的起点。
“Double Checked Locking is Broken”声明描述了为什么 DCL 在用 Java 语言实现时没有用。
Bill Joy、Guy Steele 和 James Gosling 的 The Java Language Specification,第 2 版(Addison-Wesley,2000)的第 17 章描述了 Java 内存模型的深层细节问题。
IBM 的这篇文章描述了如何 优化 WebSphere 中的加锁使不同的事务处理并发地读取同一个状态且仅在更新的时候检查数据完整性。
IBM T.J. Watson 研究中心有一整个项目组投入到 性能管理中。
请在 developerWorks Java 技术专区查找更多的参考资料。
关于作者
Brian Goetz 是一名软件顾问,并且过去 15 年来一直是专业的软件开发人员。他是 Quiotix,一家坐落在 Los Altos,California 的软件开发和咨询公司的首席顾问。请通过 brian@quiotix.com 与 Brian 联系。
==============================================
如果我是国王:关于解决Java 编程语言线程问题的建议
Allen Holub
自由撰稿人
2000 年 10 月
Allen Holub 指出,Java 编程语言的线程模型可能是此语言中最薄弱的部分。它完全不适合实际复杂程序的要求,而且也完全不是面向对象的。本文建议对 Java 语言进行重大修改和补充,以解决这些问题。
Java 语言的线程模型是此语言的一个最难另人满意的部分。尽管 Java 语言本身就支持线程编程是件好事,但是它对线程的语法和类包的支持太少,只能适用于极小型的应用环境。
关于 Java 线程编程的大多数书籍都长篇累牍地指出了 Java 线程模型的缺陷,并提供了解决这些问题的急救包(Band-Aid/邦迪创可贴)类库。我称这些类为急救包,是因为它们所能解决的问题本应是由 Java 语言本身语法所包含的。从长远来看,以语法而不是类库方法,将能产生更高效的代码。这是因为编译器和 Java 虚拟器 (JVM) 能一同优化程序代码,而这些优化对于类库中的代码是很难或无法实现的。
在我的《Taming Java Threads》(请参阅参考资料)书中以及本文中,我进一步建议对 Java 编程语言本身进行一些修改,以使得它能够真正解决这些线程编程的问题。本文和我这本书的主要区别是,我在撰写本文时进行了更多的思考, 所以对书中的提议加以了提高。这些建议只是尝试性的 -- 只是我个人对这些问题的想法,而且实现这些想法需要进行大量的工作以及同行们的评价。但这是毕竟是一个开端,我有意为解决这些问题成立一个专门的工作组,如果您感兴趣,请发 e-mail 到 threading@holub.com。一旦我真正着手进行,我就会给您发通知。
这里提出的建议是非常大胆的。有些人建议对 Java 语言规范 (JLS)(请参阅参考资料)进行细微和少量的修改以解决当前模糊的 JVM 行为,但是我却想对其进行更为彻底的改进。
在实际草稿中,我的许多建议包括为此语言引入新的关键字。虽然通常要求不要突破一个语言的现有代码是正确的,但是如果该语言的并不是要保持不变以至于过时的话,它就必须能引入新的关键字。为了使引入的关键字与现有的标识符不产生冲突,经过细心考虑,我将使用一个 ($) 字符,而这个字符在现有的标识符中是非法的。(例如,使用 $task,而不是 task)。此时需要编译器的命令行开关提供支持,能使用这些关键字的变体,而不是忽略这个美元符号。
task(任务)的概念
Java 线程模型的根本问题是它完全不是面向对象的。面向对象 (OO) 设计人员根本不按线程角度考虑问题;他们考虑的是同步信息异步信息(同步信息被立即处理 -- 直到信息处理完成才返回消息句柄;异步信息收到后将在后台处理一段时间 -- 而早在信息处理结束前就返回消息句柄)。Java 编程语言中的 Toolkit.getImage() 方法就是异步信息的一个好例子。getImage() 的消息句柄将被立即返回,而不必等到整个图像被后台线程取回。
这是面向对象 (OO) 的处理方法。但是,如前所述,Java 的线程模型是非面向对象的。一个 Java 编程语言线程实际上只是一个 run() 过程,它调用了其它的过程。在这里就根本没有对象、异步或同步信息以及其它概念。
对于此问题,在我的书中深入讨论过的一个解决方法是,使用一个 Active_object。 active 对象是可以接收异步请求的对象,它在接收到请求后的一段时间内以后台方式得以处理。在 Java 编程语言中,一个请求可被封装在一个对象中。例如,你可以把一个通过 Runnable接口实现的实例传送给此 active 对象,该接口的 run() 方法封装了需要完成的工作。该 runnable 对象被此 active 对象排入到队列中,当轮到它执行时,active 对象使用一个后台线程来执行它。
在一个 active 对象上运行的异步信息实际上是同步的,因为它们被一个单一的服务线程按顺序从队列中取出并执行。因此,使用一个 active 对象以一种更为过程化的模型可以消除大多数的同步问题。
在某种意义上,Java 编程语言的整个 Swing/AWT 子系统是一个 active 对象。向一个 Swing 队列传送一条讯息的唯一安全的途径是,调用一个类似 SwingUtilities.invokeLater() 的方法,这样就在 Swing 事件队列上发送了一个 runnable 对象,当轮到它执行时, Swing 事件处理线程将会处理它。
那么我的第一个建议是,向 Java 编程语言中加入一个 task(任务)的概念,从而将active 对象集成到语言中。( task的概念是从 Intel 的 RMX 操作系统和 Ada 编程语言借鉴过来的。大多数实时操作系统都支持类似的概念。)
一个任务有一个内置的 active 对象分发程序,并自动管理那些处理异步信息的全部机制。
定义一个任务和定义一个类基本相同,不同的只是需要在任务的方法前加一个 asynchronous 修饰符来指示 active 对象的分配程序在后台处理这些方法。请参考我的书中第九章的基于类方法,再看以下的 file_io 类,它使用了在《Taming Java Threads》中所讨论的 Active_object 类来实现异步写操作:
interface Exception_handler
{ void handle_exception( Throwable e );
}
class File_io_task
{ Active_object dispatcher = new Active_object();
final OutputStream file;
final Exception_handler handler;
File_io_task( String file_name, Exception_handler handler )
throws IOException
{ file = new FileOutputStream( file_name );
this.handler = handler;
}
public void write( final byte[] bytes )
{
// The following call asks the active-object dispatcher
// to enqueue the Runnable object on its request
// queue. A thread associated with the active object
// dequeues the runnable objects and executes them
// one at a time.
dispatcher.dispatch
( new Runnable()
{ public void run()
{
try
{ byte[] copy new byte[ bytes.length ];
System.arrayCopy( bytes, 0,
copy, 0,
bytes.length );
file.write( copy );
}
catch( Throwable problem )
{ handler.handle_exception( problem );
}
}
}
);
}
}
所有的写请求都用一个 dispatch() 过程调用被放在 active-object 的输入队列中排队。在后台处理这些异步信息时出现的任何异常 (exception) 都由 Exception_handler 对象处理,此 Exception_handler 对象被传送到 File_io_task 的构造函数中。您要写内容到文件时,代码如下:
File_io_task io = new File_io_task
( "foo.txt"
new Exception_handler
{ public void handle( Throwable e )
{ e.printStackTrace();
}
}
);
//...
io.write( some_bytes );
这种基于类的处理方法,其主要问题是太复杂了 -- 对于一个这样简单的操作,代码太杂了。向 Java 语言引入 $task 和 $asynchronous 关键字后,就可以按下面这样重写以前的代码:
$task File_io $error{ $.printStackTrace(); }
{
OutputStream file;
File_io( String file_name ) throws IOException
{ file = new FileOutputStream( file_name );
}
asynchronous public write( byte[] bytes )
{ file.write( bytes );
}
}
注意,异步方法并没有指定返回值,因为其句柄将被立即返回,而不用等到请求的操作处理完成后。所以,此时没有合理的返回值。对于派生出的模型, $task 关键字和 class 一样同效:$task 可以实现接口、继承类和继承的其它任务。标有 asynchronous 关键字的方法由 $task 在后台处理。其它的方法将同步运行,就像在类中一样。
$task 关键字可以用一个可选的 $error 从句修饰 (如上所示),它表明对任何无法被异步方法本身捕捉的异常将有一个缺省的处理程序。我使用 $ 来代表被抛出的异常对象。如果没有指定 $error 从句,就将打印出一个合理的出错信息(很可能是堆栈跟踪信息)。
注意,为确保线程安全,异步方法的参数必须是不变 (immutable) 的。运行时系统应通过相关语义来保证这种不变性(简单的复制通常是不够的)。
所有的 task 对象必须支持一些伪信息 (pseudo-message),例如:
some_task.close() 在此调用后发送的任何异步信息都产生一个 TaskClosedException。但是,在 active 对象队列上等候的消息仍能被提供。
some_task.join() 调用程序被阻断,直到此任务关闭、而且所有未完成的请求都被处理完毕。
除了常用的修饰符(public 等),task 关键字还应接受一个 $pooled(n) 修饰符,它导致 task 使用一个线程池,而不是使用单个线程来运行异步请求。n 指定了所需线程池的大小;必要时,此线程池可以增加,但是当不再需要线程时,它应该缩到原来的大小。伪域 (pseudo-field) $pool_size 返回在 $pooled(n) 中指定的原始 n 参数值。
在《Taming Java Threads》的第八章中,我给出了一个服务器端的 socket 处理程序,作为线程池的例子。它是关于使用线程池的任务的一个好例子。其基本思路是产生一个独立对象,它的任务是监控一个服务器端的 socket。每当一个客户机连接到服务器时,服务器端的对象会从池中抓取一个预先创建的睡眠线程,并把此线程设置为服务于客户端连接。socket 服务器会产出一个额外的客户服务线程,但是当连接关闭时,这些额外的线程将被删除。实现 socket 服务器的推荐语法如下:
public $pooled(10) $task Client_handler
{
PrintWriter log = new PrintWriter( System.out );
public asynchronous void handle( Socket connection_to_the_client )
{
log.println("writing");
// client-handling code goes here. Every call to
// handle() is executed on its own thread, but 10
// threads are pre-created for this purpose. Additional
// threads are created on an as-needed basis, but are
// discarded when handle() returns.
}
}
$task Socket_server
{
ServerSocket server;
Client_handler client_handlers = new Client_handler();
public Socket_server( int port_number )
{ server = new ServerSocket(port_number);
}
public $asynchronous listen(Client_handler client)
{
// This method is executed on its own thread.
while( true )
{ client_handlers.handle( server.accept() );
}
}
}
//...
Socket_server = new Socket_server( the_port_number );
server.listen()
Socket_server 对象使用一个独立的后台线程处理异步的 listen() 请求,它封装 socket 的“接受”循环。当每个客户端连接时,listen() 请求一个 Client_handler 通过调用 handle() 来处理请求。每个 handle() 请求在它们自己的线程中执行(因为这是一个 $pooled 任务)。
注意,每个传送到 $pooled $task 的异步消息实际上都使用它们自己的线程来处理。典型情况下,由于一个 $pooled $task 用于实现一个自主操作;所以对于解决与访问状态变量有关的潜在的同步问题,最好的解决方法是在 $asynchronous 方法中使用 this 是指向的对象的一个独有副本。这就是说,当向一个 $pooled $task 发送一个异步请求时,将执行一个 clone() 操作,并且此方法的 this 指针会指向此克隆对象。线程之间的通信可通过对 static 区的同步访问实现。
改进 synchronized
虽然在多数情况下, $task 消除了同步操作的要求,但是不是所有的多线程系统都用任务来实现。所以,还需要改进现有的线程模块。 synchronized 关键字有下列缺点:
无法指定一个超时值。
无法中断一个正在等待请求锁的线程。
无法安全地请求多个锁 。(多个锁只能以依次序获得。)
解决这些问题的办法是:扩展 synchronized 的语法,使它支持多个参数和能接受一个超时说明(在下面的括弧中指定)。下面是我希望的语法:
synchronized(x && y && z) 获得 x、y 和 z 对象的锁。
synchronized(x || y || z) 获得 x、y 或 z 对象的锁。
synchronized( (x && y ) || z) 对于前面代码的一些扩展。
synchronized(...)[1000] 设置 1 秒超时以获得一个锁。
synchronized[1000] f(){...} 在进入 f() 函数时获得 this 的锁,但可有 1 秒超时。
TimeoutException 是 RuntimeException 派生类,它在等待超时后即被抛出。
超时是需要的,但还不足以使代码强壮。您还需要具备从外部中止请求锁等待的能力。所以,当向一个等待锁的线程传送一个 interrupt() 方法后,此方法应抛出一个 SynchronizationException 对象,并中断等待的线程。这个异常应是 RuntimeException 的一个派生类,这样不必特别处理它。
对 synchronized 语法这些推荐的更改方法的主要问题是,它们需要在二进制代码级上修改。而目前这些代码使用进入监控(enter-monitor)和退出监控(exit-monitor)指令来实现 synchronized。而这些指令没有参数,所以需要扩展二进制代码的定义以支持多个锁定请求。但是这种修改不会比在 Java 2 中修改 Java 虚拟机的更轻松,但它是向下兼容现存的 Java 代码。
另一个可解决的问题是最常见的死锁情况,在这种情况下,两个线程都在等待对方完成某个操作。设想下面的一个例子(假设的):
class Broken
{ Object lock1 = new Object();
Object lock2 = new Object();
void a()
{ synchronized( lock1 )
{ synchronized( lock2 )
{ // do something
}
}
}
void b()
{ synchronized( lock2 )
{ synchronized( lock1 )
{ // do something
}
}
}
设想一个线程调用 a(),但在获得 lock1之后在获得 lock2 之前被剥夺运行权。 第二个线程进入运行,调用 b(),获得了 lock2,但是由于第一个线程占用 lock1,所以它无法获得 lock1,所以它随后处于等待状态。此时第一个线程被唤醒,它试图获得 lock2,但是由于被第二个线程占据,所以无法获得。此时出现死锁。下面的 synchronize-on-multiple-objects 的语法可解决这个问题:
//...
void a()
{ synchronized( lock1 && lock2 )
{
}
}
void b()
{ synchronized( lock2 && lock3 )
{
}
}
编译器(或虚拟机)会重新排列请求锁的顺序,使 lock1 总是被首先获得,这就消除了死锁。
但是,这种方法对多线程不一定总成功,所以得提供一些方法来自动打破死锁。一个简单的办法就是在等待第二个锁时常释放已获得的锁。这就是说,应采取如下的等待方式,而不是永远等待:
while( true )
{ try
{ synchronized( some_lock )[10]
{ // do the work here.
break;
}
}
catch( TimeoutException e )
{ continue;
}
}
如果等待锁的每个程序使用不同的超时值,就可打破死锁而其中一个线程就可运行。我建议用以下的语法来取代前面的代码:
synchronized( some_lock )[]
{ // do the work here.
}
synchronized 语句将永远等待,但是它时常会放弃已获得的锁以打破潜在的死锁可能。在理想情况下,每个重复等待的超时值比前一个相差一随机值。
改进 wait() 和 notify()
wait()/notify() 系统也有一些问题:
无法检测 wait() 是正常返回还是因超时返回。
无法使用传统条件变量来实现处于一个“信号”(signaled)状态。
太容易发生嵌套的监控(monitor)锁定。
超时检测问题可以通过重新定义 wait() 使它返回一个 boolean 变量 (而不是 void ) 来解决。一个 true 返回值指示一个正常返回,而 false 指示因超时返回。
基于状态的条件变量的概念是很重要的。如果此变量被设置成 false 状态,那么等待的线程将要被阻断,直到此变量进入 true 状态;任何等待 true 的条件变量的等待线程会被自动释放。 (在这种情况下,wait() 调用不会发生阻断。)。通过如下扩展 notify() 的语法,可以支持这个功能:
notify(); 释放所有等待的线程,而不改变其下面的条件变量的状态。
notify(true); 把条件变量的状态设置为 true 并释放任何等待的进程。其后对于 wait() 的调用不会发生阻断。
notify(false); 把条件变量的状态设置为 false (其后对于 wait() 的调用会发生阻断)。
嵌套监控锁定问题非常麻烦,我并没有简单的解决办法。嵌套监控锁定是一种死锁形式,当某个锁的占有线程在挂起其自身之前不释放锁时,会发生这种嵌套监控封锁。下面是此问题的一个例子(还是假设的),但是实际的例子是非常多的
先是synchronized的适用场合,对象,作用以及必要性和副作用
场合:多线程并发访问资源
作用:为资源(比如变量,结构,文件等)加锁
副作用:同步造成延迟等待,没有多线程环境的情况下不要使用,用了这个关键字可以保证安全性,但同时效率就会有所降低。
例子?简单的:
一:多个客户端(jsp?servlet?)访问一个静态全局变量
Object xxx = ...getApplicationObject();
synchronized(xxx){
//更新该变量
}
二:有些容器也会用到,比如Vector和Hashtable就用了synchronized关键字
三:Array(1000)你给他附值,你就用synchronized
因为附值要一定时间,这期间其他不能访问数组
-----------------
synchronized 关键字,它包括两种用法:synchronized 方法和 synchronized 块。
1. synchronized 方法:通过在方法声明中加入 synchronized关键字来声明 synchronized 方法。如:
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 (如前所述,可以是类实例或类)的锁方能执行,具体机制同前所述。由于可以针对任意代码块,且可任意指定上锁的对象,故灵活性较高。
-------------------------
###静态(static)方法synchronized的问题###
在Java中,使用synchronized关键字来进行线程的同步
一个设置了synchronized关键字的方法自动成为同步方法
进入方法则相当于以当前对象为信号量,上锁。退出时解锁。
Java还提供了wait()和notify(),notifyAll()方法辅助进行同步
wait()会解锁当前的信号量,使线程进入堵塞状态。直到使用
同一信号量的notify()或notifyAll()来唤醒它。
线程在不同时刻有不同的状态,正在被执行时,即占有CPU时
我们称之为Running状态。那么其它的状态线程自然是不能运行啦。
很可能有多个线程都不能运行吧,但它们不能运行的原因却是各不相同的。
那么由于相同原因不能运行的线程就会存在一个“池pool”中。
由于虚拟机线程调度而不运行的,处于 Runnable池中,表示万事俱备,
只差CPU,由于使用synchronized而阻塞的线程就会在对象锁
(object's lock pool)池中等待。而同步的方法中调用wait()后,线程则会
在wait池中等待。这里要注意了,首先wait()方法能得到执行说明当前线程
正在运行(在Running状态),从而说明它肯定拥有对象锁;第二,调用
wait()方法进入阻塞状态时,当前线程将释放对象锁(!!)第三,
在notify()或notifyAll()方法唤醒此线程时,它将进入 object's lock pool
池中等待,以重新获得对象锁。状态图如下所示。
Schedule
(Runnable) <-------------> (Running)
^ | \
| | --\
| synchronized wait() --must hav lock
acquire lock | \ release lock
| | \
| | \
| (Blocked in) | (Bolcked in )
----------( object's )<---- ( object's )
( lock pool) <---------- ( wait pool )
notify()
------------------------
对一种特殊的资源——对象中的内存——Java提供了内建的机制来防止它们的冲突。由于我们通常将数据元素设为从属于private(私有)类,然后只通过方法访问那些内存,所以只需将一个特定的方法设为synchronized(同步的),便可有效地防止冲突。在任何时刻,只可有一个线程调用特定对象的一个synchronized方法(尽管那个线程可以调用多个对象的同步方法)。下面列出简单的synchronized方法:
synchronized void f() { /* ... */ }
synchronized void g() { /* ... */ }
每个对象都包含了一把锁(也叫作“监视器”),它自动成为对象的一部分(不必为此写任何特殊的代码)。调用任何synchronized方法时,对象就会被锁定,不可再调用那个对象的其他任何synchronized方法,除非第一个方法完成了自己的工作,并解除锁定。在上面的例子中,如果为一个对象调用f(),便不能再为同样的对象调用g(),除非f()完成并解除锁定。因此,一个特定对象的所有synchronized方法都共享着一把锁,而且这把锁能防止多个方法对通用内存同时进行写操作(比如同时有多个线程)。
每个类也有自己的一把锁(作为类的Class对象的一部分),所以synchronized static方法可在一个类的范围内被相互间锁定起来,防止与static数据的接触。
×××静态方法是对类锁定,普通方法是对对象锁定
-------------
import java.net.*;
import java.io.*;
public class SyncTest extends Thread
{
int whichfunc=0;
public static void main(String [] args)throws Exception
{
SyncTest syn1=new SyncTest(1);
SyncTest syn2=new SyncTest(2);
syn1.join();
syn2.join();
}
public SyncTest(int which)
{
whichfunc=which;
start();
}
public void run()
{
try{
if(whichfunc==1){
func1();
}
else if(whichfunc==2)
{
func2();
}
}
catch(Exception e)
{
e.printStackTrace();
}
}
private static int order=0;
private synchronized static void func1()throws Exception
{
System.out.println("this is func1,value is "+order++);
Thread.sleep(2000);
System.out.println("end of func1,value is "+order);
}
private synchronized static void func2()throws Exception
{
System.out.println("this is func2,value is "+order++);
Thread.sleep(2000);
System.out.println("end of func2,value is "+order);
}
}
对比func1和func2
如果两着都是synchronized static 则两者被同步
如果两者都只是static 而不是synchronized,则不能被同步
如果两者都只是synchronized而不是static 也不能被同步,因为他们不是同一对象的方法。
如果两者都是synchronized,但是有一个不是static 而另一个是,
也不能被同步
所以我认为synchronized作用在static方法前的时候,是对特定的Class对象在全局中同步。
synchronized作用在非static方法前的时候,是对特定的对象同步。
http://www.javaworld.com/javaworld/jw-04-1999/jw-04-toolbox.html
但是非静态变量也可以模拟static方法的同步
例如对于上面两个例子中func1改为
private void func1()throws Exception
{
synchronized(this.getClass())
{
System.out.println("this is func1,value is "+order++);
Thread.sleep(2000);
System.out.println("end of func1,value is "+order);
}
}
效果是一样的。
非static 方法是必须有所依托的对象的,而static方法在语义上是不需要有一个对象。synchronized 有作用域的问题,不是对整个JVM都有效,这一点必须明白。
synchronized 实际上是对一个锁的加琐和释放的问题。对非static方法,synchronized的作用域只是所依托的对象而不是全局唯一的,而 static 方法synchronized作用域是类的Class对象,由于这个对象是全局唯一的,所以static 方法是一个时候只能有一个访问他。在这种意义上我们也可以把static 方法看看成是非static的方法,只不过是一个Class对象的方法。
因此sync同static 是有关系的。
被sync的方法和块在某一时刻只有一个thread在调用他???
当两个对象实例线程调用一个sync的非static方法时候,sync不起任何作用,这是经过理论和实践检验的。
-------------------------------------------------------------------------
编写多线程的Java 应用程序
如何避免当前编程中最常见的问题
Alex Roetter (aroetter@CS.Stanford.edu)
Teton Data Systems 的软件工程师
2001 年 2 月
Java Thread API 允许程序员编写具有多处理机制优点的应用程序,在后台处理任务的同时保持用户所需的交互感。Alex Roetter 介绍了 Java Thread API,并概述多线程可能引起的问题以及常见问题的解决方案
几乎所有使用 AWT 或 Swing 编写的画图程序都需要多线程。但多线程程序会造成许多困难,刚开始编程的开发者常常会发现他们被一些问题所折磨,例如不正确的程序行为或死锁。
在本文中,我们将探讨使用多线程时遇到的问题,并提出那些常见陷阱的解决方案。
线程是什么?
一个程序或进程能够包含多个线程,这些线程可以根据程序的代码执行相应的指令。多线程看上去似乎在并行执行它们各自的工作,就像在一台计算机上运行着多个处理机一样。在多处理机计算机上实现多线程时,它们确实 可以并行工作。和进程不同的是,线程共享地址空间。也就是说,多个线程能够读写相同的变量或数据结构。
编写多线程程序时,你必须注意每个线程是否干扰了其他线程的工作。可以将程序看作一个办公室,如果不需要共享办公室资源或与其他人交流,所有职员就会独立并行地工作。某个职员若要和其他人交谈,当且仅当该职员在“听”且他们两说同样的语言。此外,只有在复印机空闲且处于可用状态(没有仅完成一半的复印工作,没有纸张阻塞等问题)时,职员才能够使用它。在这篇文章中你将看到,在 Java 程序中互相协作的线程就好像是在一个组织良好的机构中工作的职员。
在多线程程序中,线程可以从准备就绪队列中得到,并在可获得的系统 CPU 上运行。操作系统可以将线程从处理器移到准备就绪队列或阻塞队列中,这种情况可以认为是处理器“挂起”了该线程。同样,Java 虚拟机 (JVM) 也可以控制线程的移动——在协作或抢先模型中——从准备就绪队列中将进程移到处理器中,于是该线程就可以开始执行它的程序代码。
协作式线程模型允许线程自己决定什么时候放弃处理器来等待其他的线程。程序开发员可以精确地决定某个线程何时会被其他线程挂起,允许它们与对方有效地合作。缺点在于某些恶意或是写得不好的线程会消耗所有可获得的 CPU 时间,导致其他线程“饥饿”。
在抢占式线程模型中,操作系统可以在任何时候打断线程。通常会在它运行了一段时间(就是所谓的一个时间片)后才打断它。这样的结果自然是没有线程能够不公平地长时间霸占处理器。然而,随时可能打断线程就会给程序开发员带来其他麻烦。同样使用办公室的例子,假设某个职员抢在另一人前使用复印机,但打印工作在未完成的时候离开了,另一人接着使用复印机时,该复印机上可能就还有先前那名职员留下来的资料。抢占式线程模型要求线程正确共享资源,协作式模型却要求线程共享执行时间。由于 JVM 规范并没有特别规定线程模型,Java 开发员必须编写可在两种模型上正确运行的程序。在了解线程以及线程间通讯的一些方面之后,我们可以看到如何为这两种模型设计程序。
线程和 Java 语言
为了使用 Java 语言创建线程,你可以生成一个 Thread 类(或其子类)的对象,并给这个对象发送 start() 消息。(程序可以向任何一个派生自 Runnable 接口的类对象发送 start() 消息。)每个线程动作的定义包含在该线程对象的 run() 方法中。run 方法就相当于传统程序中的 main() 方法;线程会持续运行,直到 run() 返回为止,此时该线程便死了。
上锁
大多数应用程序要求线程互相通信来同步它们的动作。在 Java 程序中最简单实现同步的方法就是上锁。为了防止同时访问共享资源,线程在使用资源的前后可以给该资源上锁和开锁。假想给复印机上锁,任一时刻只有一个职员拥有钥匙。若没有钥匙就不能使用复印机。给共享变量上锁就使得 Java 线程能够快速方便地通信和同步。某个线程若给一个对象上了锁,就可以知道没有其他线程能够访问该对象。即使在抢占式模型中,其他线程也不能够访问此对象,直到上锁的线程被唤醒、完成工作并开锁。那些试图访问一个上锁对象的线程通常会进入睡眠状态,直到上锁的线程开锁。一旦锁被打开,这些睡眠进程就会被唤醒并移到准备就绪队列中。
在 Java 编程中,所有的对象都有锁。线程可以使用 synchronized 关键字来获得锁。在任一时刻对于给定的类的实例,方法或同步的代码块只能被一个线程执行。这是因为代码在执行之前要求获得对象的锁。继续我们关于复印机的比喻,为了避免复印冲突,我们可以简单地对复印资源实行同步。如同下列的代码例子,任一时刻只允许一位职员使用复印资源。通过使用方法(在 Copier 对象中)来修改复印机状态。这个方法就是同步方法。只有一个线程能够执行一个 Copier 对象中同步代码,因此那些需要使用 Copier 对象的职员就必须排队等候。
class CopyMachine {
public synchronized void makeCopies(Document d, int nCopies) {
//only one thread executes this at a time
}
public void loadPaper() {
//multiple threads could access this at once!
synchronized(this) {
//only one thread accesses this at a time
//feel free to use shared resources, overwrite members, etc.
}
}
}
Fine-grain 锁
在对象级使用锁通常是一种比较粗糙的方法。为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。由于每个对象都有锁,可以如下所示使用虚拟对象来上锁:
class FineGrainLock {
MyMemberClass x, y;
Object xlock = new Object(), ylock = new Object();
public void foo() {
synchronized(xlock) {
//access x here
}
//do something here - but don't use shared resources
synchronized(ylock) {
//access y here
}
}
public void bar() {
synchronized(this) {
//access both x and y here
}
//do something here - but don't use shared resources
}
}
若为了在方法级上同步,不能将整个方法声明为 synchronized 关键字。它们使用的是成员锁,而不是 synchronized 方法能够获得的对象级锁。
信号量
通常情况下,可能有多个线程需要访问数目很少的资源。假想在服务器上运行着若干个回答客户端请求的线程。这些线程需要连接到同一数据库,但任一时刻只能获得一定数目的数据库连接。你要怎样才能够有效地将这些固定数目的数据库连接分配给大量的线程?一种控制访问一组资源的方法(除了简单地上锁之外),就是使用众所周知的信号量计数 (counting semaphore)。信号量计数将一组可获得资源的管理封装起来。信号量是在简单上锁的基础上实现的,相当于能令线程安全执行,并初始化为可用资源个数的计数器。例如我们可以将一个信号量初始化为可获得的数据库连接个数。一旦某个线程获得了信号量,可获得的数据库连接数减一。线程消耗完资源并释放该资源时,计数器就会加一。当信号量控制的所有资源都已被占用时,若有线程试图访问此信号量,则会进入阻塞状态,直到有可用资源被释放。
信号量最常见的用法是解决“消费者-生产者问题”。当一个线程进行工作时,若另外一个线程访问同一共享变量,就可能产生此问题。消费者线程只能在生产者线程完成生产后才能够访问数据。使用信号量来解决这个问题,就需要创建一个初始化为零的信号量,从而让消费者线程访问此信号量时发生阻塞。每当完成单位工作时,生产者线程就会向该信号量发信号(释放资源)。每当消费者线程消费了单位生产结果并需要新的数据单元时,它就会试图再次获取信号量。因此信号量的值就总是等于生产完毕可供消费的数据单元数。这种方法比采用消费者线程不停检查是否有可用数据单元的方法要高效得多。因为消费者线程醒来后,倘若没有找到可用的数据单元,就会再度进入睡眠状态,这样的操作系统开销是非常昂贵的。
尽管信号量并未直接被 Java 语言所支持,却很容易在给对象上锁的基础上实现。一个简单的实现方法如下所示:
class Semaphore {
private int count;
public Semaphore(int n) {
this.count = n;
}
public synchronized void acquire() {
while(count == 0) {
try {
wait();
} catch (InterruptedException e) {
//keep trying
}
}
count--;
}
public synchronized void release() {
count++;
notify(); //alert a thread that's blocking on this semaphore
}
}
常见的上锁问题
不幸的是,使用上锁会带来其他问题。让我们来看一些常见问题以及相应的解决方法:
死锁。死锁是一个经典的多线程问题,因为不同的线程都在等待那些根本不可能被释放的锁,从而导致所有的工作都无法完成。假设有两个线程,分别代表两个饥饿的人,他们必须共享刀叉并轮流吃饭。他们都需要获得两个锁:共享刀和共享叉的锁。假如线程 "A" 获得了刀,而线程 "B" 获得了叉。线程 A 就会进入阻塞状态来等待获得叉,而线程 B 则阻塞来等待 A 所拥有的刀。这只是人为设计的例子,但尽管在运行时很难探测到,这类情况却时常发生。虽然要探测或推敲各种情况是非常困难的,但只要按照下面几条规则去设计系统,就能够避免死锁问题:
让所有的线程按照同样的顺序获得一组锁。这种方法消除了 X 和 Y 的拥有者分别等待对方的资源的问题。
将多个锁组成一组并放到同一个锁下。前面死锁的例子中,可以创建一个银器对象的锁。于是在获得刀或叉之前都必须获得这个银器的锁。
将那些不会阻塞的可获得资源用变量标志出来。当某个线程获得银器对象的锁时,就可以通过检查变量来判断是否整个银器集合中的对象锁都可获得。如果是,它就可以获得相关的锁,否则,就要释放掉银器这个锁并稍后再尝试。
最重要的是,在编写代码前认真仔细地设计整个系统。多线程是困难的,在开始编程之前详细设计系统能够帮助你避免难以发现死锁的问题。
Volatile 变量. volatile 关键字是 Java 语言为优化编译器设计的。以下面的代码为例:
class VolatileTest {
public void foo() {
boolean flag = false;
if(flag) {
//this could happen
}
}
}
一个优化的编译器可能会判断出 if 部分的语句永远不会被执行,就根本不会编译这部分的代码。如果这个类被多线程访问, flag 被前面某个线程设置之后,在它被 if 语句测试之前,可以被其他线程重新设置。用 volatile 关键字来声明变量,就可以告诉编译器在编译的时候,不需要通过预测变量值来优化这部分的代码。
无法访问的线程 有时候虽然获取对象锁没有问题,线程依然有可能进入阻塞状态。在 Java 编程中 IO 就是这类问题最好的例子。当线程因为对象内的 IO 调用而阻塞时,此对象应当仍能被其他线程访问。该对象通常有责任取消这个阻塞的 IO 操作。造成阻塞调用的线程常常会令同步任务失败。如果该对象的其他方法也是同步的,当线程被阻塞时,此对象也就相当于被冷冻住了。其他的线程由于不能获得对象的锁,就不能给此对象发消息(例如,取消 IO 操作)。必须确保不在同步代码中包含那些阻塞调用,或确认在一个用同步阻塞代码的对象中存在非同步方法。尽管这种方法需要花费一些注意力来保证结果代码安全运行,但它允许在拥有对象的线程发生阻塞后,该对象仍能够响应其他线程。
为不同的线程模型进行设计
判断是抢占式还是协作式的线程模型,取决于虚拟机的实现者,并根据各种实现而不同。因此,Java 开发员必须编写那些能够在两种模型上工作的程序。
正如前面所提到的,在抢占式模型中线程可以在代码的任何一个部分的中间被打断,除非那是一个原子操作代码块。原子操作代码块中的代码段一旦开始执行,就要在该线程被换出处理器之前执行完毕。在 Java 编程中,分配一个小于 32 位的变量空间是一种原子操作,而此外象 double 和 long 这两个 64 位数据类型的分配就不是原子的。使用锁来正确同步共享资源的访问,就足以保证一个多线程程序在抢占式模型下正确工作。
而在协作式模型中,是否能保证线程正常放弃处理器,不掠夺其他线程的执行时间,则完全取决于程序员。调用 yield() 方法能够将当前的线程从处理器中移出到准备就绪队列中。另一个方法则是调用 sleep() 方法,使线程放弃处理器,并且在 sleep 方法中指定的时间间隔内睡眠。
正如你所想的那样,将这些方法随意放在代码的某个地方,并不能够保证正常工作。如果线程正拥有一个锁(因为它在一个同步方法或代码块中),则当它调用 yield() 时不能够释放这个锁。这就意味着即使这个线程已经被挂起,等待这个锁释放的其他线程依然不能继续运行。为了缓解这个问题,最好不在同步方法中调用 yield 方法。将那些需要同步的代码包在一个同步块中,里面不含有非同步的方法,并且在这些同步代码块之外才调用 yield。
另外一个解决方法则是调用 wait() 方法,使处理器放弃它当前拥有的对象的锁。如果对象在方法级别上使同步的,这种方法能够很好的工作。因为它仅仅使用了一个锁。如果它使用 fine-grained 锁,则 wait() 将无法放弃这些锁。此外,一个因为调用 wait() 方法而阻塞的线程,只有当其他线程调用 notifyAll() 时才会被唤醒。
线程和 AWT/Swing
在那些使用 Swing 和/或 AWT 包创建 GUI (用户图形界面)的 Java 程序中,AWT 事件句柄在它自己的线程中运行。开发员必须注意避免将这些 GUI 线程与较耗时间的计算工作绑在一起,因为这些线程必须负责处理用户时间并重绘用户图形界面。换句话来说,一旦 GUI 线程处于繁忙,整个程序看起来就象无响应状态。Swing 线程通过调用合适方法,通知那些 Swing callback (例如 Mouse Listener 和 Action Listener )。 这种方法意味着 listener 无论要做多少事情,都应当利用 listener callback 方法产生其他线程来完成此项工作。目的便在于让 listener callback 更快速返回,从而允许 Swing 线程响应其他事件。
如果一个 Swing 线程不能够同步运行、响应事件并重绘输出,那怎么能够让其他的线程安全地修改 Swing 的状态?正如上面提到的,Swing callback 在 Swing 线程中运行。因此他们能修改 Swing 数据并绘到屏幕上。
但是如果不是 Swing callback 产生的变化该怎么办呢?使用一个非 Swing 线程来修改 Swing 数据是不安全的。Swing 提供了两个方法来解决这个问题:invokeLater() 和 invokeAndWait()。为了修改 Swing 状态,只要简单地调用其中一个方法,让 Runnable 的对象来做这些工作。因为 Runnable 对象通常就是它们自身的线程,你可能会认为这些对象会作为线程来执行。但那样做其实也是不安全的。事实上,Swing 会将这些对象放到队列中,并在将来某个时刻执行它的 run 方法。这样才能够安全修改 Swing 状态。
总结
Java 语言的设计,使得多线程对几乎所有的 Applet 都是必要的。特别是,IO 和 GUI 编程都需要多线程来为用户提供完美的体验。如果依照本文所提到的若干基本规则,并在开始编程前仔细设计系统——包括它对共享资源的访问等,你就可以避免许多常见和难以发觉的线程陷阱。
资料
参考 Java 2 平台上的 API 规范说明书(1.3 版标准):Java 2 API 文档.
更多关于 JVM 对线程和上锁处理的信息,可以参阅 Java 虚拟机规范说明书.
Allen Holub 的 Taming Java Threads (APress, June 2000) 是一本极好的参考书
你可能还希望阅读 Allen 的文章 如果我是国王:关于解决 Java 编程语言线程问题的建议 (developerWorks, October 2000), 里面阐述了一些被他称为“一门伟大语言最虚弱之处”的问题。
关于作者
Alex Roetter 已经有数年关于用 Java 以及其他编程语言编写多线程应用程序的经验,在斯坦福大学获得了计算机科学学士学位。你可以通过 aroetter@CS.Stanford.edu 与 Alex 联系。
==========================
轻松使用线程: 同步不是敌人
我们什么时候需要同步,而同步的代价到底有多大?
Brian Goetz (brian@quiotix.com)
软件顾问,Quiotix
2001 年 7 月
与许多其它的编程语言不同,Java 语言规范包括对线程和并发的明确支持。语言本身支持并发,这使得指定和管理共享数据的约束以及跨线程操作的计时变得更简单,但是这没有使得并发编程的复杂性更易于理解。这个三部分的系列文章的目的在于帮助程序员理解用 Java 语言进行多线程编程的一些主要问题,特别是线程安全对 Java 程序性能的影响。
请点击文章顶部或底部的讨论进入由 Brian Goetz 主持的 “Java 线程:技巧、窍门和技术”讨论论坛,与本文作者和其他读者交流您对本文或整个多线程的想法。注意该论坛讨论的是使用多线程时遇到的所有问题,而并不限于本文的内容。
大多数编程语言的语言规范都不会谈到线程和并发的问题;因为一直以来,这些问题都是留给平台或操作系统去详细说明的。但是,Java 语言规范(JLS)却明确包括一个线程模型,并提供了一些语言元素供开发人员使用以保证他们程序的线程安全。
对线程的明确支持有利也有弊。它使得我们在写程序时更容易利用线程的功能和便利,但同时也意味着我们不得不注意所写类的线程安全,因为任何类都很有可能被用在一个多线程的环境内。
许多用户第一次发现他们不得不去理解线程的概念的时候,并不是因为他们在写创建和管理线程的程序,而是因为他们正在用一个本身是多线程的工具或框架。任何用过 Swing GUI 框架或写过小服务程序或 JSP 页的开发人员(不管有没有意识到)都曾经被线程的复杂性困扰过。
Java 设计师是想创建一种语言,使之能够很好地运行在现代的硬件,包括多处理器系统上。要达到这一目的,管理线程间协调的工作主要推给了软件开发人员;程序员必须指定线程间共享数据的位置。在 Java 程序中,用来管理线程间协调工作的主要工具是 synchronized 关键字。在缺少同步的情况下,JVM 可以很自由地对不同线程内执行的操作进行计时和排序。在大部分情况下,这正是我们想要的,因为这样可以提高性能,但它也给程序员带来了额外的负担,他们不得不自己识别什么时候这种性能的提高会危及程序的正确性。
synchronized 真正意味着什么?
大部分 Java 程序员对同步的块或方法的理解是完全根据使用互斥(互斥信号量)或定义一个临界段(一个必须原子性地执行的代码块)。虽然 synchronized 的语义中确实包括互斥和原子性,但在管程进入之前和在管程退出之后发生的事情要复杂得多。
synchronized 的语义确实保证了一次只有一个线程可以访问被保护的区段,但同时还包括同步线程在主存内互相作用的规则。理解 Java 内存模型(JMM)的一个好方法就是把各个线程想像成运行在相互分离的处理器上,所有的处理器存取同一块主存空间,每个处理器有自己的缓存,但这些缓存可能并不总和主存同步。在缺少同步的情况下,JMM 会允许两个线程在同一个内存地址上看到不同的值。而当用一个管程(锁)进行同步的时候,一旦申请加了锁,JMM 就会马上要求该缓存失效,然后在它被释放前对它进行刷新(把修改过的内存位置写回主存)。不难看出为什么同步会对程序的性能影响这么大;频繁地刷新缓存代价会很大。
使用一条好的运行路线
如果同步不适当,后果是很严重的:会造成数据混乱和争用情况,导致程序崩溃,产生不正确的结果,或者是不可预计的运行。更糟的是,这些情况可能很少发生且具有偶然性(使得问题很难被监测和重现)。如果测试环境和开发环境有很大的不同,无论是配置的不同,还是负荷的不同,都有可能使得这些问题在测试环境中根本不出现,从而得出错误的结论:我们的程序是正确的,而事实上这些问题只是还没出现而已。
争用情况定义
争用情况是一种特定的情况:两个或更多的线程或进程读或写一些共享数据,而最终结果取决于这些线程是如何被调度计时的。争用情况可能会导致不可预见的结果和隐蔽的程序错误。
另一方面,不当或过度地使用同步会导致其它问题,比如性能很差和死锁。当然,性能差虽然不如数据混乱那么严重,但也是一个严重的问题,因此同样不可忽视。编写优秀的多线程程序需要使用好的运行路线,足够的同步可以使您的数据不发生混乱,但不需要滥用到去承担死锁或不必要地削弱程序性能的风险。
同步的代价有多大?
由于包括缓存刷新和设置失效的过程,Java 语言中的同步块通常比许多平台提供的临界段设备代价更大,这些临界段通常是用一个原子性的“test and set bit”机器指令实现的。即使一个程序只包括一个在单一处理器上运行的单线程,一个同步的方法调用仍要比非同步的方法调用慢。如果同步时还发生锁定争用,那么性能上付出的代价会大得多,因为会需要几个线程切换和系统调用。
幸运的是,随着每一版的 JVM 的不断改进,既提高了 Java 程序的总体性能,同时也相对减少了同步的代价,并且将来还可能会有进一步的改进。此外,同步的性能代价经常是被夸大的。一个著名的资料来源就曾经引证说一个同步的方法调用比一个非同步的方法调用慢 50 倍。虽然这句话有可能是真的,但也会产生误导,而且已经导致了许多开发人员即使在需要的时候也避免使用同步。
严格依照百分比计算同步的性能损失并没有多大意义,因为一个无争用的同步给一个块或方法带来的是固定的性能损失。而这一固定的延迟带来的性能损失百分比取决于在该同步块内做了多少工作。对一个 空方法的同步调用可能要比对一个空方法的非同步调用慢 20 倍,但我们多长时间才调用一次空方法呢?当我们用更有代表性的小方法来衡量同步损失时,百分数很快就下降到可以容忍的范围之内。
表 1 把一些这种数据放在一起来看。它列举了一些不同的实例,不同的平台和不同的 JVM 下一个同步的方法调用相对于一个非同步的方法调用的损失。在每一个实例下,我运行一个简单的程序,测定循环调用一个方法 10,000,000 次所需的运行时间,我调用了同步和非同步两个版本,并比较了结果。表格中的数据是同步版本的运行时间相对于非同步版本的运行时间的比率;它显示了同步的性能损失。每次运行调用的都是清单 1 中的简单方法之一。
表格 1 中显示了同步方法调用相对于非同步方法调用的相对性能;为了用绝对的标准测定性能损失,必须考虑到 JVM 速度提高的因素,这并没有在数据中体现出来。在大多数测试中,每个 JVM 的更高版本都会使 JVM 的总体性能得到很大提高,很有可能 1.4 版的 Java 虚拟机发行的时候,它的性能还会有进一步的提高。
表 1. 无争用同步的性能损失
JDK staticEmpty empty fetch hashmapGet singleton create
Linux / JDK 1.1 9.2 2.4 2.5 n/a 2.0 1.42
Linux / IBM Java SDK 1.1 33.9 18.4 14.1 n/a 6.9 1.2
Linux / JDK 1.2 2.5 2.2 2.2 1.64 2.2 1.4
Linux / JDK 1.3 (no JIT) 2.52 2.58 2.02 1.44 1.4 1.1
Linux / JDK 1.3 -server 28.9 21.0 39.0 1.87 9.0 2.3
Linux / JDK 1.3 -client 21.2 4.2 4.3 1.7 5.2 2.1
Linux / IBM Java SDK 1.3 8.2 33.4 33.4 1.7 20.7 35.3
Linux / gcj 3.0 2.1 3.6 3.3 1.2 2.4 2.1
Solaris / JDK 1.1 38.6 20.1 12.8 n/a 11.8 2.1
Solaris / JDK 1.2 39.2 8.6 5.0 1.4 3.1 3.1
Solaris / JDK 1.3 (no JIT) 2.0 1.8 1.8 1.0 1.2 1.1
Solaris / JDK 1.3 -client 19.8 1.5 1.1 1.3 2.1 1.7
Solaris / JDK 1.3 -server 1.8 2.3 53.0 1.3 4.2 3.2
清单 1. 基准测试中用到的简单方法
public static void staticEmpty() { }
public void empty() { }
public Object fetch() { return field; }
public Object singleton() {
if (singletonField == null)
singletonField = new Object();
return singletonField;
}
public Object hashmapGet() {
return hashMap.get("this");
}
public Object create() {
return new Object();
}
这些小基准测试也阐明了存在动态编译器的情况下解释性能结果所面临的挑战。对于 1.3 JDK 在有和没有 JIT 时,数字上的巨大差异需要给出一些解释。对那些非常简单的方法(empty 和 fetch),基准测试的本质(它只是执行一个几乎什么也不做的紧凑的循环)使得 JIT 可以动态地编译整个循环,把运行时间压缩到几乎没有的地步。但在一个实际的程序中,JIT 能否这样做就要取决于很多因素了,所以,无 JIT 的计时数据可能在做公平对比时更有用一些。在任何情况下,对于更充实的方法( create 和 hashmapGet),JIT 就不能象对更简单些的方法那样使非同步的情况得到巨大的改进。另外,从数据中看不出 JVM 是否能够对测试的重要部分进行优化。同样,在可比较的 IBM 和 Sun JDK 之间的差异反映了 IBM Java SDK 可以更大程度地优化非同步的循环,而不是同步版本代价更高。这在纯计时数据中可以明显地看出(这里不提供)。
从这些数字中我们可以得出以下结论:对非争用同步而言,虽然存在性能损失,但在运行许多不是特别微小的方法时,损失可以降到一个合理的水平;大多数情况下损失大概在 10% 到 200% 之间(这是一个相对较小的数目)。所以,虽然同步每个方法是不明智的(这也会增加死锁的可能性),但我们也不需要这么害怕同步。这里使用的简单测试是说明一个无争用同步的代价要比创建一个对象或查找一个 HashMap 的代价小。
由于早期的书籍和文章暗示了无争用同步要付出巨大的性能代价,许多程序员就竭尽全力避免同步。这种恐惧导致了许多有问题的技术出现,比如说 double-checked locking(DCL)。许多关于 Java 编程的书和文章都推荐 DCL,它看上去真是避免不必要的同步的一种聪明的方法,但实际上它根本没有用,应该避免使用它。DCL 无效的原因很复杂,已超出了本文讨论的范围(要深入了解,请参阅参考资料里的链接)。
不要争用
假设同步使用正确,若线程真正参与争用加锁,您也能感受到同步对实际性能的影响。并且无争用同步和争用同步间的性能损失差别很大;一个简单的测试程序指出争用同步比无争用同步慢 50 倍。把这一事实和我们上面抽取的观察数据结合在一起,可以看出使用一个争用同步的代价至少相当于创建 50 个对象。
所以,在调试应用程序中同步的使用时,我们应该努力减少实际争用的数目,而根本不是简单地试图避免使用同步。这个系列的第 2 部分将把重点放在减少争用的技术上,包括减小锁的粒度、减小同步块的大小以及减小线程间共享数据的数量。
什么时候需要同步?
要使您的程序线程安全,首先必须确定哪些数据将在线程间共享。如果正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。有些程序员可能会惊讶地发现,这些规则在简单地检查一个共享引用是否非空的时候也用得上。
许多人会发现这些定义惊人地严格。有一种普遍的观点是,如果只是要读一个对象的字段,不需要请求加锁,尤其是在 JLS 保证了 32 位读操作的原子性的情况下,它更是如此。但不幸的是,这个观点是错误的。除非所指的字段被声明为 volatile,否则 JMM 不会要求下面的平台提供处理器间的缓存一致性和顺序连贯性,所以很有可能,在某些平台上,没有同步就会读到陈旧的数据。有关更详细的信息,请参阅参考资料。
在确定了要共享的数据之后,还要确定要如何保护那些数据。在简单情况下,只需把它们声明为 volatile 即可保护数据字段;在其它情况下,必须在读或写共享数据前请求加锁,一个很好的经验是明确指出使用什么锁来保护给定的字段或对象,并在你的代码里把它记录下来。
还有一点值得注意的是,简单地同步存取器方法(或声明下层的字段为 volatile)可能并不足以保护一个共享字段。可以考虑下面的示例:
...
private int foo;
public synchronized int getFoo() { return foo; }
public synchronized void setFoo(int f) { foo = f; }
如果一个调用者想要增加 foo 属性值,以下完成该功能的代码就不是线程安全的:
...
setFoo(getFoo() + 1);
如果两个线程试图同时增加 foo 属性值,结果可能是 foo 的值增加了 1 或 2,这由计时决定。调用者将需要同步一个锁,才能防止这种争用情况;一个好方法是在 JavaDoc 类中指定同步哪个锁,这样类的调用者就不需要自己猜了。
以上情况是一个很好的示例,说明我们应该注意多层次粒度的数据完整性;同步存取器方法确保调用者能够存取到一致的和最近版本的属性值,但如果希望属性的将来值与当前值一致,或多个属性间相互一致,我们就必须同步复合操作 — 可能是在一个粗粒度的锁上。
如果情况不确定,考虑使用同步包装
有时,在写一个类的时候,我们并不知道它是否要用在一个共享环境里。我们希望我们的类是线程安全的,但我们又不希望给一个总是在单线程环境内使用的类加上同步的负担,而且我们可能也不知道使用这个类时合适的锁粒度是多大。幸运的是,通过提供同步包装,我们可以同时达到以上两个目的。Collections 类就是这种技术的一个很好的示例;它们是非同步的,但在框架中定义的每个接口都有一个同步包装(例如, Collections.synchronizedMap()),它用一个同步的版本来包装每个方法。
结论
虽然 JLS 给了我们可以使我们的程序线程安全的工具,但线程安全也不是天上掉下来的馅饼。使用同步会蒙受性能损失,而同步使用不当又会使我们承担数据混乱、结果不一致或死锁的风险。幸运的是,在过去的几年内 JVM 有了很大的改进,大大减少了与正确使用同步相关的性能损失。通过仔细分析在线程间如何共享数据,适当地同步对共享数据的操作,可以使得您的程序既是线程安全的,又不会承受过多的性能负担。
参考资料
请点击文章顶部或底部的讨论进入由 Brian Goetz 主持的,关于“Java 线程:技巧、窍门和技术”的 讨论论坛。
Jack Shirazi 编写的 Java Performance Tuning(O'Reilly & Associates, 2000)可以为在 Java 平台上解决性能问题提供指导。本书引用的与本书一起提供的参考资料提供了很好的性能调试技巧。
Dov Bulka 的 Java Performance and Scalability,第 1 卷:Server-Side Programming Techniques(Addison-Wesley,2000)提供了大量的设计技巧和诀窍,可帮助您增强自己的应用程序的性能。
Steve Wilson 和 Jeff Kesselman 的 Java Platform Performance: Strategies and Tactics(Addison-Wesley,2000)为有经验的 Java 程序员提供了生成快速、有效的 Java 代码的技术。
Brian Goetz 最近的著作“Double-checked locking: Clever, but broken”(JavaWorld,2001 年 2 月)详细探索了 JMM 并描述了特定情况下不使用同步的惊人后果。
公认的多线程权威 Allen Holub 在他的文章“警告:多处理器世界中的线程”(JavaWorld,2001 年 2 月)中揭示了为什么用于减少同步负担的大多数技巧都不起作用。
Peter Haggar 描述了怎样用固定的,循环的顺序获取多个锁定以避免死锁(developerWorks,2000 年 9 月)。
在他的文章“编写多线程 Java 应用”(developerWorks,2001 年 9 月)中,Alex Roetter 介绍了 Java Thread API,概括了多线程涉及的问题,并为一般性的问题提供了解决方案。
Doug Lea 的 Concurrent Programming in Java,第 2 版(Addison-Wesley,1999)是关于 Java 语言中多线程编程的敏感问题的权威书籍。
“同步和 Java 内存模型”摘录自 Doug Lea 的关于 synchronized 的实际意义的著作。
Bill Pugh 的 Java 内存模型为您学习 JMM 提供了一个很好的起点。
“Double Checked Locking is Broken”声明描述了为什么 DCL 在用 Java 语言实现时没有用。
Bill Joy、Guy Steele 和 James Gosling 的 The Java Language Specification,第 2 版(Addison-Wesley,2000)的第 17 章描述了 Java 内存模型的深层细节问题。
IBM 的这篇文章描述了如何 优化 WebSphere 中的加锁使不同的事务处理并发地读取同一个状态且仅在更新的时候检查数据完整性。
IBM T.J. Watson 研究中心有一整个项目组投入到 性能管理中。
请在 developerWorks Java 技术专区查找更多的参考资料。
关于作者
Brian Goetz 是一名软件顾问,并且过去 15 年来一直是专业的软件开发人员。他是 Quiotix,一家坐落在 Los Altos,California 的软件开发和咨询公司的首席顾问。请通过 brian@quiotix.com 与 Brian 联系。
==============================================
如果我是国王:关于解决Java 编程语言线程问题的建议
Allen Holub
自由撰稿人
2000 年 10 月
Allen Holub 指出,Java 编程语言的线程模型可能是此语言中最薄弱的部分。它完全不适合实际复杂程序的要求,而且也完全不是面向对象的。本文建议对 Java 语言进行重大修改和补充,以解决这些问题。
Java 语言的线程模型是此语言的一个最难另人满意的部分。尽管 Java 语言本身就支持线程编程是件好事,但是它对线程的语法和类包的支持太少,只能适用于极小型的应用环境。
关于 Java 线程编程的大多数书籍都长篇累牍地指出了 Java 线程模型的缺陷,并提供了解决这些问题的急救包(Band-Aid/邦迪创可贴)类库。我称这些类为急救包,是因为它们所能解决的问题本应是由 Java 语言本身语法所包含的。从长远来看,以语法而不是类库方法,将能产生更高效的代码。这是因为编译器和 Java 虚拟器 (JVM) 能一同优化程序代码,而这些优化对于类库中的代码是很难或无法实现的。
在我的《Taming Java Threads》(请参阅参考资料)书中以及本文中,我进一步建议对 Java 编程语言本身进行一些修改,以使得它能够真正解决这些线程编程的问题。本文和我这本书的主要区别是,我在撰写本文时进行了更多的思考, 所以对书中的提议加以了提高。这些建议只是尝试性的 -- 只是我个人对这些问题的想法,而且实现这些想法需要进行大量的工作以及同行们的评价。但这是毕竟是一个开端,我有意为解决这些问题成立一个专门的工作组,如果您感兴趣,请发 e-mail 到 threading@holub.com。一旦我真正着手进行,我就会给您发通知。
这里提出的建议是非常大胆的。有些人建议对 Java 语言规范 (JLS)(请参阅参考资料)进行细微和少量的修改以解决当前模糊的 JVM 行为,但是我却想对其进行更为彻底的改进。
在实际草稿中,我的许多建议包括为此语言引入新的关键字。虽然通常要求不要突破一个语言的现有代码是正确的,但是如果该语言的并不是要保持不变以至于过时的话,它就必须能引入新的关键字。为了使引入的关键字与现有的标识符不产生冲突,经过细心考虑,我将使用一个 ($) 字符,而这个字符在现有的标识符中是非法的。(例如,使用 $task,而不是 task)。此时需要编译器的命令行开关提供支持,能使用这些关键字的变体,而不是忽略这个美元符号。
task(任务)的概念
Java 线程模型的根本问题是它完全不是面向对象的。面向对象 (OO) 设计人员根本不按线程角度考虑问题;他们考虑的是同步信息异步信息(同步信息被立即处理 -- 直到信息处理完成才返回消息句柄;异步信息收到后将在后台处理一段时间 -- 而早在信息处理结束前就返回消息句柄)。Java 编程语言中的 Toolkit.getImage() 方法就是异步信息的一个好例子。getImage() 的消息句柄将被立即返回,而不必等到整个图像被后台线程取回。
这是面向对象 (OO) 的处理方法。但是,如前所述,Java 的线程模型是非面向对象的。一个 Java 编程语言线程实际上只是一个 run() 过程,它调用了其它的过程。在这里就根本没有对象、异步或同步信息以及其它概念。
对于此问题,在我的书中深入讨论过的一个解决方法是,使用一个 Active_object。 active 对象是可以接收异步请求的对象,它在接收到请求后的一段时间内以后台方式得以处理。在 Java 编程语言中,一个请求可被封装在一个对象中。例如,你可以把一个通过 Runnable接口实现的实例传送给此 active 对象,该接口的 run() 方法封装了需要完成的工作。该 runnable 对象被此 active 对象排入到队列中,当轮到它执行时,active 对象使用一个后台线程来执行它。
在一个 active 对象上运行的异步信息实际上是同步的,因为它们被一个单一的服务线程按顺序从队列中取出并执行。因此,使用一个 active 对象以一种更为过程化的模型可以消除大多数的同步问题。
在某种意义上,Java 编程语言的整个 Swing/AWT 子系统是一个 active 对象。向一个 Swing 队列传送一条讯息的唯一安全的途径是,调用一个类似 SwingUtilities.invokeLater() 的方法,这样就在 Swing 事件队列上发送了一个 runnable 对象,当轮到它执行时, Swing 事件处理线程将会处理它。
那么我的第一个建议是,向 Java 编程语言中加入一个 task(任务)的概念,从而将active 对象集成到语言中。( task的概念是从 Intel 的 RMX 操作系统和 Ada 编程语言借鉴过来的。大多数实时操作系统都支持类似的概念。)
一个任务有一个内置的 active 对象分发程序,并自动管理那些处理异步信息的全部机制。
定义一个任务和定义一个类基本相同,不同的只是需要在任务的方法前加一个 asynchronous 修饰符来指示 active 对象的分配程序在后台处理这些方法。请参考我的书中第九章的基于类方法,再看以下的 file_io 类,它使用了在《Taming Java Threads》中所讨论的 Active_object 类来实现异步写操作:
interface Exception_handler
{ void handle_exception( Throwable e );
}
class File_io_task
{ Active_object dispatcher = new Active_object();
final OutputStream file;
final Exception_handler handler;
File_io_task( String file_name, Exception_handler handler )
throws IOException
{ file = new FileOutputStream( file_name );
this.handler = handler;
}
public void write( final byte[] bytes )
{
// The following call asks the active-object dispatcher
// to enqueue the Runnable object on its request
// queue. A thread associated with the active object
// dequeues the runnable objects and executes them
// one at a time.
dispatcher.dispatch
( new Runnable()
{ public void run()
{
try
{ byte[] copy new byte[ bytes.length ];
System.arrayCopy( bytes, 0,
copy, 0,
bytes.length );
file.write( copy );
}
catch( Throwable problem )
{ handler.handle_exception( problem );
}
}
}
);
}
}
所有的写请求都用一个 dispatch() 过程调用被放在 active-object 的输入队列中排队。在后台处理这些异步信息时出现的任何异常 (exception) 都由 Exception_handler 对象处理,此 Exception_handler 对象被传送到 File_io_task 的构造函数中。您要写内容到文件时,代码如下:
File_io_task io = new File_io_task
( "foo.txt"
new Exception_handler
{ public void handle( Throwable e )
{ e.printStackTrace();
}
}
);
//...
io.write( some_bytes );
这种基于类的处理方法,其主要问题是太复杂了 -- 对于一个这样简单的操作,代码太杂了。向 Java 语言引入 $task 和 $asynchronous 关键字后,就可以按下面这样重写以前的代码:
$task File_io $error{ $.printStackTrace(); }
{
OutputStream file;
File_io( String file_name ) throws IOException
{ file = new FileOutputStream( file_name );
}
asynchronous public write( byte[] bytes )
{ file.write( bytes );
}
}
注意,异步方法并没有指定返回值,因为其句柄将被立即返回,而不用等到请求的操作处理完成后。所以,此时没有合理的返回值。对于派生出的模型, $task 关键字和 class 一样同效:$task 可以实现接口、继承类和继承的其它任务。标有 asynchronous 关键字的方法由 $task 在后台处理。其它的方法将同步运行,就像在类中一样。
$task 关键字可以用一个可选的 $error 从句修饰 (如上所示),它表明对任何无法被异步方法本身捕捉的异常将有一个缺省的处理程序。我使用 $ 来代表被抛出的异常对象。如果没有指定 $error 从句,就将打印出一个合理的出错信息(很可能是堆栈跟踪信息)。
注意,为确保线程安全,异步方法的参数必须是不变 (immutable) 的。运行时系统应通过相关语义来保证这种不变性(简单的复制通常是不够的)。
所有的 task 对象必须支持一些伪信息 (pseudo-message),例如:
some_task.close() 在此调用后发送的任何异步信息都产生一个 TaskClosedException。但是,在 active 对象队列上等候的消息仍能被提供。
some_task.join() 调用程序被阻断,直到此任务关闭、而且所有未完成的请求都被处理完毕。
除了常用的修饰符(public 等),task 关键字还应接受一个 $pooled(n) 修饰符,它导致 task 使用一个线程池,而不是使用单个线程来运行异步请求。n 指定了所需线程池的大小;必要时,此线程池可以增加,但是当不再需要线程时,它应该缩到原来的大小。伪域 (pseudo-field) $pool_size 返回在 $pooled(n) 中指定的原始 n 参数值。
在《Taming Java Threads》的第八章中,我给出了一个服务器端的 socket 处理程序,作为线程池的例子。它是关于使用线程池的任务的一个好例子。其基本思路是产生一个独立对象,它的任务是监控一个服务器端的 socket。每当一个客户机连接到服务器时,服务器端的对象会从池中抓取一个预先创建的睡眠线程,并把此线程设置为服务于客户端连接。socket 服务器会产出一个额外的客户服务线程,但是当连接关闭时,这些额外的线程将被删除。实现 socket 服务器的推荐语法如下:
public $pooled(10) $task Client_handler
{
PrintWriter log = new PrintWriter( System.out );
public asynchronous void handle( Socket connection_to_the_client )
{
log.println("writing");
// client-handling code goes here. Every call to
// handle() is executed on its own thread, but 10
// threads are pre-created for this purpose. Additional
// threads are created on an as-needed basis, but are
// discarded when handle() returns.
}
}
$task Socket_server
{
ServerSocket server;
Client_handler client_handlers = new Client_handler();
public Socket_server( int port_number )
{ server = new ServerSocket(port_number);
}
public $asynchronous listen(Client_handler client)
{
// This method is executed on its own thread.
while( true )
{ client_handlers.handle( server.accept() );
}
}
}
//...
Socket_server = new Socket_server( the_port_number );
server.listen()
Socket_server 对象使用一个独立的后台线程处理异步的 listen() 请求,它封装 socket 的“接受”循环。当每个客户端连接时,listen() 请求一个 Client_handler 通过调用 handle() 来处理请求。每个 handle() 请求在它们自己的线程中执行(因为这是一个 $pooled 任务)。
注意,每个传送到 $pooled $task 的异步消息实际上都使用它们自己的线程来处理。典型情况下,由于一个 $pooled $task 用于实现一个自主操作;所以对于解决与访问状态变量有关的潜在的同步问题,最好的解决方法是在 $asynchronous 方法中使用 this 是指向的对象的一个独有副本。这就是说,当向一个 $pooled $task 发送一个异步请求时,将执行一个 clone() 操作,并且此方法的 this 指针会指向此克隆对象。线程之间的通信可通过对 static 区的同步访问实现。
改进 synchronized
虽然在多数情况下, $task 消除了同步操作的要求,但是不是所有的多线程系统都用任务来实现。所以,还需要改进现有的线程模块。 synchronized 关键字有下列缺点:
无法指定一个超时值。
无法中断一个正在等待请求锁的线程。
无法安全地请求多个锁 。(多个锁只能以依次序获得。)
解决这些问题的办法是:扩展 synchronized 的语法,使它支持多个参数和能接受一个超时说明(在下面的括弧中指定)。下面是我希望的语法:
synchronized(x && y && z) 获得 x、y 和 z 对象的锁。
synchronized(x || y || z) 获得 x、y 或 z 对象的锁。
synchronized( (x && y ) || z) 对于前面代码的一些扩展。
synchronized(...)[1000] 设置 1 秒超时以获得一个锁。
synchronized[1000] f(){...} 在进入 f() 函数时获得 this 的锁,但可有 1 秒超时。
TimeoutException 是 RuntimeException 派生类,它在等待超时后即被抛出。
超时是需要的,但还不足以使代码强壮。您还需要具备从外部中止请求锁等待的能力。所以,当向一个等待锁的线程传送一个 interrupt() 方法后,此方法应抛出一个 SynchronizationException 对象,并中断等待的线程。这个异常应是 RuntimeException 的一个派生类,这样不必特别处理它。
对 synchronized 语法这些推荐的更改方法的主要问题是,它们需要在二进制代码级上修改。而目前这些代码使用进入监控(enter-monitor)和退出监控(exit-monitor)指令来实现 synchronized。而这些指令没有参数,所以需要扩展二进制代码的定义以支持多个锁定请求。但是这种修改不会比在 Java 2 中修改 Java 虚拟机的更轻松,但它是向下兼容现存的 Java 代码。
另一个可解决的问题是最常见的死锁情况,在这种情况下,两个线程都在等待对方完成某个操作。设想下面的一个例子(假设的):
class Broken
{ Object lock1 = new Object();
Object lock2 = new Object();
void a()
{ synchronized( lock1 )
{ synchronized( lock2 )
{ // do something
}
}
}
void b()
{ synchronized( lock2 )
{ synchronized( lock1 )
{ // do something
}
}
}
设想一个线程调用 a(),但在获得 lock1之后在获得 lock2 之前被剥夺运行权。 第二个线程进入运行,调用 b(),获得了 lock2,但是由于第一个线程占用 lock1,所以它无法获得 lock1,所以它随后处于等待状态。此时第一个线程被唤醒,它试图获得 lock2,但是由于被第二个线程占据,所以无法获得。此时出现死锁。下面的 synchronize-on-multiple-objects 的语法可解决这个问题:
//...
void a()
{ synchronized( lock1 && lock2 )
{
}
}
void b()
{ synchronized( lock2 && lock3 )
{
}
}
编译器(或虚拟机)会重新排列请求锁的顺序,使 lock1 总是被首先获得,这就消除了死锁。
但是,这种方法对多线程不一定总成功,所以得提供一些方法来自动打破死锁。一个简单的办法就是在等待第二个锁时常释放已获得的锁。这就是说,应采取如下的等待方式,而不是永远等待:
while( true )
{ try
{ synchronized( some_lock )[10]
{ // do the work here.
break;
}
}
catch( TimeoutException e )
{ continue;
}
}
如果等待锁的每个程序使用不同的超时值,就可打破死锁而其中一个线程就可运行。我建议用以下的语法来取代前面的代码:
synchronized( some_lock )[]
{ // do the work here.
}
synchronized 语句将永远等待,但是它时常会放弃已获得的锁以打破潜在的死锁可能。在理想情况下,每个重复等待的超时值比前一个相差一随机值。
改进 wait() 和 notify()
wait()/notify() 系统也有一些问题:
无法检测 wait() 是正常返回还是因超时返回。
无法使用传统条件变量来实现处于一个“信号”(signaled)状态。
太容易发生嵌套的监控(monitor)锁定。
超时检测问题可以通过重新定义 wait() 使它返回一个 boolean 变量 (而不是 void ) 来解决。一个 true 返回值指示一个正常返回,而 false 指示因超时返回。
基于状态的条件变量的概念是很重要的。如果此变量被设置成 false 状态,那么等待的线程将要被阻断,直到此变量进入 true 状态;任何等待 true 的条件变量的等待线程会被自动释放。 (在这种情况下,wait() 调用不会发生阻断。)。通过如下扩展 notify() 的语法,可以支持这个功能:
notify(); 释放所有等待的线程,而不改变其下面的条件变量的状态。
notify(true); 把条件变量的状态设置为 true 并释放任何等待的进程。其后对于 wait() 的调用不会发生阻断。
notify(false); 把条件变量的状态设置为 false (其后对于 wait() 的调用会发生阻断)。
嵌套监控锁定问题非常麻烦,我并没有简单的解决办法。嵌套监控锁定是一种死锁形式,当某个锁的占有线程在挂起其自身之前不释放锁时,会发生这种嵌套监控封锁。下面是此问题的一个例子(还是假设的),但是实际的例子是非常多的
相关推荐
Java中的`synchronized`关键字是多线程编程中的一个重要概念,用于控制并发访问共享资源,以保证数据的一致性和完整性。这个关键词提供了互斥锁机制,防止多个线程同时执行同一段代码,确保了线程安全。 一、`...
在Java编程语言中,`synchronized`关键字是一个非常重要的概念,它用于实现线程同步,以确保多线程环境下的数据一致性与安全性。本示例"java synchronized demo"旨在探讨`synchronized`关键字的用法及其作用机制。...
Java中的`synchronized`关键字是多线程编程中用于同步控制的关键元素,它的主要目标是解决并发环境下多个线程对共享资源的访问冲突。在Java中,由于线程共享内存空间,如果没有适当的同步机制,可能会导致数据不一致...
"基于Java synchronized同步锁实现线程交互" Java多线程能够提高CPU利用效率,但也容易造成线程不安全、线程死锁等问题。Java synchronized同步锁可以保证同一时刻只有一个线程操作同一资源,使用wait()、notify()...
### Java synchronized 关键字原理与自定义锁实现详解 #### 一、Java synchronized 关键字原理 `synchronized` 是 Java 中的关键字之一,用于实现线程间的同步控制,确保共享资源的安全访问。它主要应用于以下两种...
Java synchronized关键字是Java多线程编程中至关重要的一个概念,它用于实现线程同步,确保在并发环境下数据的一致性和完整性。这篇文档主要探讨了Java synchronized的锁机制,包括锁粗化、偏向锁、轻量级锁、重量级...
### JAVA synchronized详解 在Java编程语言中,`synchronized`是一个非常重要的关键字,它主要用于实现同步控制机制。通过使用`synchronized`,可以确保同一时刻只有一个线程能够访问被标记为同步的方法或代码块,...
"Java 中 synchronized 用法详解" Synchronized 是 Java 语言中用于解决多线程共享数据同步问题的关键字。它可以作为函数的修饰符,也可以作为函数内的语句,用于实现同步方法和同步语句块。在 Java 中,...
Java中的`synchronized`关键字是多线程编程中的一个重要概念,用于控制并发访问共享资源,确保数据的正确性和一致性。在Java中,每个对象都有一个与之关联的锁,也称为同步锁,它允许线程在访问共享资源时进行互斥...
Java synchronized 学习 Java 中的 synchronized 关键字是用来实现线程同步的,它可以用来修饰方法、代码块和静态方法,以确保在多线程环境下数据的一致性。 一、进程和线程的区别 在计算机中,每个运行着的 xxxx...
在Java多线程编程中,`synchronized`关键字是一个至关重要的工具,用于实现线程间的同步,以确保共享资源的安全访问。本实例大全将全面解析`synchronized`的使用方式,包括同步方法、同步语句块、类锁和对象锁。 ##...
java synchronized详解
"Java 锁机制 Synchronized" Java 锁机制 Synchronized 是 Java 语言中的一种同步机制,用于解决多线程并发访问共享资源时可能出现的一些问题。 Java 锁机制 Synchronized 的概念 在 Java 中,每个对象都可以被...
Java中的`synchronized`关键字是多线程编程中的一个重要概念,用于控制并发访问共享资源时的同步机制。在Java中,当多个线程试图同时访问和修改同一块代码或数据时,可能会导致数据不一致的问题。为了解决这个问题,...
Java中的`synchronized`关键字是用于实现线程同步的重要机制,它的主要目的是确保在多线程环境中,对于共享资源的访问能够保持线程安全。当`synchronized`关键字应用于方法或代码块时,它提供了互斥访问,即在任意...
在Java编程语言中,`synchronized`关键字是用于处理多线程并发访问共享资源时的同步控制机制。它确保了在同一时刻,只有一个线程能够执行特定的代码块或方法,从而避免了数据的不一致性问题。以下是对`synchronized`...
Java中的`synchronized`关键字是用来解决多线程环境下的并发访问问题,确保共享资源在同一时间只能被一个线程访问,从而避免数据的不一致性。在Java中,`synchronized`可以应用于方法或者代码块,实现线程同步。 1....
Java中的`synchronized`关键字是实现线程同步的关键机制,它确保了在多线程环境下对共享资源的访问得以正确协调,防止数据不一致和竞态条件的发生。在Java语言中,支持多线程是一个核心特性,而`synchronized`就是...
由浅入深解析synchronized锁的机制,各种锁的概念的介绍,膨胀过程,基于redis的分布式锁demo。