站内搜索: 请输入搜索关键词

当前页面: 开发资料首页Java 专题Java Thread应该注意的问题

Java Thread应该注意的问题

摘要: Java Thread应该注意的问题

(wang hailong)
Java的线程编程非常简单。但有时会看到一些关于线程的错误用法。下面列出一些应该注意的问题。
1.同步对象的恒定性 All java objects are references.
对于局部变量和参数来说,java里面的int, float, double, boolean等基本数据类型,都在栈上。这些基本类型是无法同步的;java里面的对象(根对象是Object),全都在堆里,指向对象的reference在栈上。
java中的同步对象,实际上是对于reference所指的“对象地址”进行同步。 需要注意的问题是,千万不要对同步对象重新赋值。举个例子。 class A implements Runnable{ Object lock = new Object();
void run(){ for(...){ synchronized(lock){ // do something ... lock = new Object(); } } }
run函数里面的这段同步代码实际上是毫无意义的。因为每一次lock都给重新分配了新的对象的reference,每个线程都在新的reference同步。 大家可能觉得奇怪,怎么会举这么一个例子。因为我见过这样的代码,同步对象在其它的函数里被重新赋了新值。 这种问题很难查出来。 所以,一般应该把同步对象声明为final. final Object lock = new Object();
使用Singleton Pattern 设计模式来获取同步对象,也是一种很好的选择。
2.如何放置共享数据 实现线程,有两种方法,一种是继承Thread类,一种是实现Runnable接口。
上面举的例子,采用实现Runnable接口的方法。本文推荐这种方法。
首先,把需要共享的数据放在一个实现Runnable接口的类里面,然后,把这个类的实例传给多个Thread的构造方法。这样,新创建的多个Thread,都共同拥有一个Runnable实例,共享同一份数据。
如果采用继承Thread类的方法,就只好使用static静态成员了。如果共享的数据比较多,就需要大量的static静态成员,令程序数据结构混乱,难以扩展。这种情况应该尽量避免。
编写一段多线程代码,处理一个稍微复杂点的问题。两种方法的优劣,一试便知。
3.同步的粒度 线程同步的粒度越小越好,即,线程同步的代码块越小越好。尽量避免用synchronized修饰符来声明方法。尽量使用synchronized(anObject)的方式,如果不想引入新的同步对象,使用synchronized(this)的方式。而且,synchronized代码块越小越好。
4.线程之间的通知 这里使用“通知”这个词,而不用“通信”这个词,是为了避免词义的扩大化。
线程之间的通知,通过Object对象的wait()和notify() 或notifyAll() 方法实现。
下面用一个例子,来说明其工作原理:
假设有两个线程,A和B。共同拥有一个同步对象,lock。
1.首先,线程A通过synchronized(lock) 获得lock同步对象,然后调用lock.wait()函数,放弃lock同步对象,线程A停止运行,进入等待队列。
2.线程B通过synchronized(lock) 获得线程A放弃的lock同步对象,做完一定的处理,然后调用 lock.notify() 或者lock.notifyAll() 通知等待队列里面的线程A。
3.线程A从等待队列里面出来,进入ready队列,等待调度。
4.线程B继续处理,出了synchronized(lock)块之后,放弃lock同步对象。
5.线程A获得lock同步对象,继续运行。
例子代码如下:
public class SharedResource implements Runnable{
Object lock = new Object();
public void run(){
// 获取当前线程的名称。
String threadName = Thread.currentThread().getName();
if( “A”.equals(threadName)){
synchronized(lock){ //线程A通过synchronized(lock) 获得lock同步对象
try{
System.out.println(“ A gives up lock.”);
lock.wait(); // 调用lock.wait()函数,放弃lock同步对象,
// 线程A停止运行,进入等待队列。
}catch(InterruptedException e){ }
// 线程A重新获得lock同步对象之后,继续运行。
System.out.println(“ A got lock again and continue to run.”);
} // end of synchronized(lock) }
if( “B”.equals(threadName)){
synchronized(lock){//线程B通过synchronized(lock) 获得线程A放弃的lock同步对象
System.out.println(“B got lock.”);
lock.notify(); //通知等待队列里面的线程A,进入ready队列,等待调度。
//线程B继续处理,出了synchronized(lock)块之后,放弃lock同步对象。
System.out.println(“B gives up lock.”);
} // end of synchronized(lock)
boolean hasLock = Thread.holdsLock(lock); // 检查B是否拥有lock同步对象。
System.out.println(“B has lock ? -- ” +hasLock); // false. } } }
public class TestMain{
public static void main(){
Runnable resource = new SharedResource();
Thread A = new Thread(resource,”A”);
A.start();
// 强迫主线程停止运行,以便线程A开始运行。
  try {
Thread.sleep(500);
}catch(InterruptedException e){ }
Thread B = new Thread(resource,”B”);
B.start(); } }
5.跨类的同步对象 对于简单的问题,可以把访问共享资源的同步代码都放在一个类里面。
但是对于复杂的问题,我们需要把问题分为几个部分来处理,需要几个不同的类来处理问题。这时,就需要在不同的类中,共享同步对象。比如,在生产者和消费者之间共享同步对象,在读者和写者之间共享同步对象。
如何在不同的类中,共享同步对象。有几种方法实现,
(1)前面讲过的方法,使用static静态成员,(或者使用Singleton Pattern.)
(2)用参数传递的方法,把同步对象传递给不同的类。
(3)利用字符串常量的“原子性”。
对于第三种方法,这里做一下解释。一般来说,程序代码中的字符串常量经过编译之后,都具有唯一性,即,内存中不会存在两份相同的字符串常量。
(通常情况下,C++,C语言程序编译之后,也具有同样的特性。)
比如,我们有如下代码。
String A = “atom”;
String B = “atom”;
我们有理由认为,A和B指向同一个字符串常量。即,A==B。
注意,声明字符串变量的代码,不符合上面的规则。
String C= new String(“atom”);
String D = new String(“atom”);
这里的C和D的声明是字符串变量的声明,所以,C != D。
有了上述的认识,我们就可以使用字符串常量作为同步对象。
比如我们在不同的类中,使用synchronized(“myLock”), “myLock”.wait(),“myLock”.notify(), 这样的代码,就能够实现不同类之间的线程同步。
本文并不强烈推荐这种用法,只是说明,有这样一种方法存在。
本文推荐第二种方法,(2)用参数传递的方法,把同步对象传递给不同的类。
作者相关文章: Com Introduction(原作) Java Thread实现读写同步(原作) A Simple Sample for Expression Tree(原作)
对该文的评论 人气:2082 lithium(2003-3-20 21:33:03)
Java Language Specification Second Edition 3.10.5 String Literals "A string literal always refers to the same instance of class String"
而且还写了测试程序说明。注意,String的intern()方法
buaawhl(2003-3-17 8:52:26)
ajoo: 写程序应该老老实实,不能投机取巧。拿编译器优化层面的细节来影响源代码,是应该永远禁止的。 而且,从代码维护的角度考虑,这样做也是不好的。 "hello world".wait(); "hello world".notify(); 这样的代码,合法,但是却是错误。你绕过了编译器的语法检查,跟magic number没什么区别。
赞同ajoo的观点。 我文中的关于介绍 字符串常量 的同步方法。可以作为一个反面例子来看。这种方法虽然成立,不要使用这种方法。
ajoo(2003-3-15 10:11:57)
"hello world"=="hello world"吗?除非jls明确声明了这个属性,否则即使一百个jvm的实现事实上都保证了这个等式(可以理解,为了提高效率,不这么做反而奇怪), 也不应该假设这个等式永远成立。 写程序应该老老实实,不能投机取巧。拿编译器优化层面的细节来影响源代码,是应该永远禁止的。
而且,从代码维护的角度考虑,这样做也是不好的。 "hello world".wait(); "hello world".notify();
这样的代码,合法,但是却是错误。你绕过了编译器的语法检查,跟magic number没什么区别。
关于final, 尽量使用final. 修改对象的状态是程序bug的一大来源。对于同步对象来说,一般你并不需要改变它的状态,更甭提改变这个reference了。
synchronized method或synchronized(this)往往不好,因为你的对象并不绝缘, 如果调用者也synchronized你的对象,那就麻烦了。
比如说,你要写一个生产者,消费者的channel类,如果你用synchronized(this), 类似于: synchronized void produce(Object obj){......wait();......notify();......}
synchronized Object consume(){......;notify();......wait();......} 好,它可能工作得很好。
但如果某个变态的掉用者这样做: Channel c = ......;
c.produce("hello"); synchronized(c){......}
就乱套啦。
所以,更安全的方法是使用一个private, final的成员变量,如: private final Object lock = new Object();
这样你的同步就跟外界绝缘了。
buaawhl(2003-3-14 13:58:08)
感谢Norwaywoods的提醒。我没有考虑到多个JVM的情况。 利用String不变性来同步,必须禁止。 我的本意是使用字符串常量。我忘了final这个关键字。 final String lock = "lock"; 在我的理解中,字符串常量是和代码段存放在一起(只读内存部分),并不在堆和运行栈里面。 C++/C对字符串常量的处理就是这么做的。JVM是否如此,我现在也不能确认。
多个JVM对代码段或常量的调用,是怎样的。这方面,我不具有相关知识。 Norwaywoods能否简单介绍一下多个JVM协同工作的原理,或者给出相关资料连接。
受益匪浅。
Norwaywoods(2003-3-14 13:37:34)
可以去看一下这篇文章。加深对编译器的理解! http://www.csdn.net/develop/article/17/17448.shtm
Norwaywoods(2003-3-14 13:34:41)
"String A = “atom”;
String B = “atom”;
我们有理由认为,A和B指向同一个字符串常量。即,A==B。"
这句话不太妥当,这只能限于一个JVM的情况。出现这种情况的原因是VM进行了加速处理,也就是一个JVM只为“a”这个字符串分配一次内存。所以,一般情况下,可以这样等同。但是,在多个VM的情况下,这种假设事不成立的!希望注意! 所以,你基于这个理论的下面这段话: “
有了上述的认识,我们就可以使用字符串常量作为同步对象。
比如我们在不同的类中,使用synchronized(“myLock”), “myLock”.wait(),“myLock”.notify(), 这样的代码,就能够实现不同类之间的线程同步。
本文并不强烈推荐这种用法,只是说明,有这样一种方法存在。
本文推荐第二种方法,(2)用参数传递的方法,把同步对象传递给不同的类。 ” 也是有问题的。在大型分布式系统中,多个JVM协同工作是非常普遍的,利用String不变性来同步,必须禁止。
streen_ken(2003-3-13 20:02:36)
好文,加油!
buaawhl(2003-3-13 15:00:23)
盒包装,这个词用的很好。 把共享的资源都放在盒内,便于管理。 共享资源是可以修改的,可以作为左值,重新赋值。 盒里面包含的共享资源的个数,可以看作盒子的大小,就是同步的粒度。 多谢。
buaawhl(2003-3-13 14:56:09)
simonyi的看法很对。 我以前寻找方法的时候,隐约想到盒包装之类的想法。simonyi澄清了我的思路。 由于,担心增加一层复杂度,引入了一个单独的同步对象,来实现多个共享资源的同步。 从长久的角度看,simonyi的提法更值得提倡。多谢simonyi。
simonyi(2003-3-13 12:21:06)
用一个容器对象来做实际应用对象的包装盒诚然是一个巧妙的办法, 但是同步对象的更新需求仍然是存在的,无法避免这个问题。
因此我建议你修改你的提法:把同步对象声明为final。 将其改为:用一个final类型的盒对象来包装数据对象,并在盒对象粒度实现同步。
aiur(2003-3-12 14:29:24)
学习
buaawhl(2003-3-12 14:25:30)
还想补充一点。 操作 和 赋值 是两个很重要的概念。 尤其是 赋值 这个概念。 赋值 由等号两边的部分组成。 等号左边称为 左值。等号右边称为右值。 final 禁止对象 成为左值。(只能成为一次左值。)
对于对象来说。 操作 改变内容。reference不变。 赋值 改变reference。这是最根本的改变。
:-)
buaawhl(2003-3-12 14:20:17)
这里说的的reference指对象地址。在C/C++中,称为地址指针。 Object lock = new Object() synchronized(lock) 这个语句实际是在指向lock的reference上同步。 线程之间竞争的是指向lock的reference。
使用java还是要考虑地址。java取消了指针的概念,反而给一些问题的理解带来了麻烦。 请参见我的另一篇关于reference的文章《By value? Or by reference?》 http://www.csdn.net/Develop/Read_Article.asp?Id=17286
:-)
buaawhl(2003-3-12 14:12:06)
simonyi的问题很对。 把同步对象声明为final,就是为了避免对同步对象重新赋值。避免改变同步对象的reference。 但我们可以对final对象进行操作。
比如,下面的代码能够通过编译器的检查 final List list = new ArrayList(); list.add(new Object()); // OK.
下面的代码不能通过编译器的检查 final List list = new ArrayList(); list = new ArrayList(); // compiling error.
simonyi(2003-3-11 16:59:41)
声明同步对象为final标志着以后永远也不能对其进行2次赋值,这个问题是否应该考虑? 难道说所有的同步对象都不能更改?特别是某些同步对象本身就是状态标志的时候,允许它的value进行更改是必要的,这时final关键字将导致update禁止。
buaawhl(2003-3-11 14:03:56)
文中关于同步对象的建议都是基于这样的前提:共享的资源比较多,我们需要 另外 设置一个同步对象,来控制对多个共享资源的访问。
如果把共享资源本身作为同步对象,以上的建议也同样成立。 比如,读写者问题。 final List buf = new ArrayList(); 写者和读者都可以对共享资源buf进行操作。 写者: buf.add(...); 读者: Object = buf.remove(...); 等等。 这里我们可以操作buf,但是不能够对buf重新赋值。 buf = new ArrayList(); 这样的语句,将无法通过编译器的检查。 于是保证 synchronized(buf)这样的语句,竞争的是同一个同步对象buf。
buaawhl(2003-3-11 13:53:44)
:-) 是的。建议把同步对象声明为final。 而且,如果使用Singleton Pattern的时候,获取同步对象的函数,也尽量设为final。 这些做法,都是为了利用编译器的编译时期检查,帮助确保线程之间竞争同一个同步对象。
受到Norwaywoods文章的启发,借助String类型的不变性,还建议尽量把同步对象声明为String类型。
simonyi(2003-3-11 11:08:15)
把同步对象声明为final? 这种建议搞笑了一点吧。
Norwaywoods(2003-3-11 10:18:07)
好文,加油! 多多交流!
↑返回目录
前一篇: Java Tip: 实现Command模式
后一篇: Java Thread实现读写同步