J2me线程详解
作者:Eric Giguere 原文:http://developers.sun.com/mobility/midp/articles/threading2/ 翻译:reallychenchi@163.com 译文:http://chenchi.blog.edu.cn/2012/765203.html
Java平台内置支持了多线程。多线程允许一个程序执行并行的操作。如果使用得当,多线程可以使得程序界面在执行需要比较长时间的操作,诸如网络访问或者很复杂的计算过程中——仍然可以相应用户操作。而界面相应在任何平台上都很重要,在主要面向手持设备和面向消费者的J2me平台更是如此。无论你是用限制较多的CLDC还是全功能的CDC,对多线程的基本认识是写出有效J2me程序的一个关键之处。本文将会讲解多线程的概念和你应该如何在程序中使用它。
- 什么是多线程?
Java Turtorial把一个线程定义为 “a single sequential flow of control within a program.”线程是程序执行的基本单元。每一个运行的程序至少有一个线程。一个程序如果包含了两个或者两个以上线程,则被认为是多线程程序。
每一个线程都有一个context。这个context包含了线程的各种信息,比如当前指令地址、局部变量的存储等待。这个context在对应线程执行的时候会更新,它也包含了线程的状态。一个线程可以处于以下状态:
运行:一个正在执行指令的线程
就绪:一个可以随时开始执行指令的线程
挂起:一个正在等待外部事件的线程,比如它正在等待其他设备发送过来的数据,一旦所等待的事件发生,此线程可以立即转换为Ready状态
结束:结束执行的线程
一个处于运行、就绪或者挂起状态的线程是一个活线程。而一个结束状态的线程是一个死线程。
虽然一个程序可能有很多个线程,但是真正的设备上只有有限个处理器(往往是一个或者两个)可以用来执行指令。线程之间会通过轮流进入运行状态来分享这些处理器。这被称为“线程调度”。
线程调度是一个复杂的过程而且程序本身很少会涉及到。系统(一般是底层的操作系统或者Java虚拟机,这取决于线程是如何在Java中实现的)会给每一个就绪状态的线程分配一小片时间运行,在不同的线程之间快速的切换,而挂起状态的线程会被忽略掉。这个“Context switching”可能在任何时候发生。而程序能对线程调度造成影响的机会只有在设置线程优先级上。高优先级的线程比低优先级的线程获得的执行时间往往要多一些。
- 线程对象
线程本质上说是动态的,只会作为运行中程序的一部分而存在。Java平台把每一个线程(注意此处不是指Java的Thread类的对象而是前文中定义的线程)都以java.lang.Thread实例包装了起来。这个类可以用来启动一个新线程并且设置线程优先级。代码中可以通过Thread.currentThread()函数随时获取当前运行的线程。
J2me提供了两种Thread类,都是J2SE 1.3 的子集。CLDC的Thread类只包括下列方法:
注意,这里没有停止或者打断一个执行中线程的方法,后文会解释。
- 启动线程
启动一个线程你需要两件事:一个java.lang.Thread的实例和一个实现了java.lang.Runnable接口的对象。
Runnable接口定义了一个没有传入参数也没有返回参数的run()函数,这里是一个简单的实现:
你可以通过创建一个Runnable对象,再用它创建一个Thread实例,然后调用后者的start()方法:
系统会启动一个新线程来调用Runnable的run函数。当线程从run函数返回以后,系统就结束这个线程。
Thread类自己就实现了Runnable接口,所以另外一个办法就是继承Thread类并且重写run方法,如下所示:
你可以通过创建这个子类的一个实例并且调用它的start方法来开启一个线程:
哪一种方式更好一些?从功能角度来说,是没有本质区别的。当然你可以在一个有其他用处的类上实现Runnable接口,这样可以避免创建一个新的类的开销。尽管这样的开销很小,但是在一些很严格的环境中即使节省一点字节也很有帮助(比如有些设备限制了J2me程序的大小不能超过30k)。另外,从Thread继承的限制更多些,因为start()函数对每一个Thread对象只能执行一次。
对于一个线程在run()函数里做什么从来没有任何限制,一个线程可以只做一点简单的操作就退出run()函数。不过一般来说往往是重复一些操作指导某条件得到满足:
需要注意的是,一个线程可以访问用来创建此线程的Runnable对象的所有方法和域。因为run()方法没有任何参数,传递参数给start()函数是唯一可以用来初始化这个线程。这里有一个简单的例子:
如果两个线程并行访问同一个数据,那么线程同步(随后讨论)就是一个需要考虑的问题了。
- 中止线程
如前文所述,J2se中Thread类的stop()和interrupt()函数在J2me中是没有的。stop()函数被裁剪掉是因为它在本质上不可靠而且不能在所有的平台上安全一致的实现出来。interrupt()函数在CLDC1.1版本重新引进了,很可能也会出现在在CDC的下个版本。
一旦启动,一个J2me线程一直“活着”直到它正常或者不正常(比如出现未捕获异常)的退出它启动的时候调用的run()函数。(系统也会在程序终止的时候终止线程,当然这就是完全不同的情况了)让一个线程终止另外一个线程是不可能的:你只能通过让一个线程自己终止掉的方式来终止一个线程。
为线程提供一个短时间内终止的办法是很重要的,最简单的是让每个线程间隔的去检查某个布尔变量:
这个方法只在仅有一个线程执行这个run()函数的时候才很有效,但是有多个线程同时分享一个run()函数(也就是说,分享同一个Runnable对象)而且你只希望部分线程终止的时候就不行了。例如,经常会只允许最近执行的线程执行而释放掉其他的线程。那么可以保存这个最近运行的线程的引用,但是在run()函数里面检查这个线程是不是最近运行的线程:
如果在线程循环中多检查几次则是一种很有效的做法,比如在耗时操作之前和之后都检查是否需要退出循环:
一般来说,我们都希望线程在退出的时候越快越好,最好延迟只有善后的时候。
如果一个线程在使用某些系统资源,比如说网络连接或者一个J2me的Record Store,那么如果它创建或者打开了这个资源的话,一定要记得用一个finally来释放这些资源,无论这个线程是怎么结束的。例如:
否则的话,如果一个线程由于未捕获异常退出但是没有释放资源,那么很可能其他线程也不能访问这个资源。使用finally同样可以避免导致程序在长时间运行的时候崩溃的内存泄漏。在J2me中,释放资源和避免内存泄漏是非常重要的。例如一些支持J2me的设备有时候会限制一个程序只能同时访问一个到两个网络连接。
一旦一个线程请求另外一个线程停止,第一个线程可以通过调用第二个线程的isAlive()函数来检查它是否还在运行。如果第一个线程需要等待第二个线程结束以后再继续,那么它可以调用join()方法:
需要注意的是,如果你调用了join方法,那么当前线程会被阻塞住。
- 线程同步
启动行业结束多个线程很容易,难的是让这几个线程能够有条不紊的协作起来。因为线程切换是可能发生任何时间的,一个刚刚开始运行的线程可能发现它正在读取的数据是刚才运行的线程才写了一半的。相关的线程需要使用线程同步的方式来控制特定时间里对共享数据的读写操作。
线程同步依赖于一个控制器。这个控制器就像看门人一样,确保在相同的时间只有一个线程在访问数据。当一个线程需要访问被保护的数据的时候,它必须先向控制器提出申请,把这个数据锁定到只能自己访问。其他的线程此时如果想访问这个数据就必须等待数据解锁,直到控制器能够为正在等待的一个线程锁定此数据并允许该线程继续处理。
通过synchronized关键字,任何一个Java对象都可以作为控制器来控制对数据的访问,例如:
如果两个线程同时调用了这个方法,那么其中之一(后进入同步区的那个)就只能在进入同步区以前等待另外一个退出同步区以后才能继续执行。
因为任何一个Java对象都可以作为监视器,所以在非静态方法中,this也可以作为同步监视器来使用:
在类内部用this作为同步监视器是一个比较简便的做法。Java甚至提供了一个更简便的语法形式:你可以把synchronized关键字添加在函数声明前面来表示此函数根据this对象同步,而不是用大括号再括一遍。例如,上面的Counter类还可以这样写:
甚至静态函数也可以用这种方式来声明,尽管并没有一个this对象作为监视器:
事实上,静态函数是用它所在的类的class object作为同步监视器的。所以上述例子等同于:
很多Java的核心类使用了synchronized修饰的方法。例如java.lang.StringBuffer每个方法都使用了synchronized修饰。就连J2me版本也是同步的。但是请注意,方法的同步并不保证不同线程调用方法的顺序,只保证对任意一个同步方法,一次只有一个线程调用。再看看appendTime()方法:
虽然StringBuffer.append()是同步修饰的,确保每一个用来添加的字符串值都是作为一个整体而添加的,appendTime()仍然需要同步修饰来确保调用的序列不会被打乱。
线程同步只有在线程交互工作的时候才有用。为了保护可修改的数据,所有的线程在访问数据的时候都必须通过一个同步修饰的代码段。如果数据是包括在一个类里面的,那么可以把类的所有(或者尽可能多的)函数用synchronized修饰,但是确保你理解你是知道哪个同步监视器是用来做同步的。例如,注意代码的改动,把同步区改成了声明函数为同步,是一个重要的改变:
新版本和前面的是不等价的,因为他们使用的同步监视器是不一样的。新版本事实上等价于:
第一个版本确保某线程在执行函数appendTime()的时候不可能有其他线程修改buf(因为buf本身就是锁),但是第二个版本只确保通过同一个实例调用appendTime()的其他的线程不会修改buf(锁是appendTime函数的this实例,而不是buf)。如果有一个线程通过另外一个实例调用函数appendTime()修改同一个StringBuffer,那么还是出现同时进入函数appendTime()的可能(使用了不同的实例调用函数appendTime,但是传人同一个buf)。
当然,线程同步也会带来代价。锁定和解锁数据需要时间,即便最快的处理器也是如此。如果你的累是不可修改的,也就是初始化以后不会改变,那么可以不考虑同步问题。例如java.lang.String累就没有定义任何同步方法。对于不会被多于一个线程访问的数据也不用考虑同步问题。
线程同步同时也带来一个隐患:如果互相等待被对方锁定的数据就要导致多线程死锁。假定线程甲锁定了对象X,线程乙锁定了对象Y,但是线程甲还需要锁定对象Y而同时线程乙又需要锁定对象X。那么这两个线程就阻塞而且无法解锁了。在设计程序的时候,避免死锁是一个很重要的考虑因素。一个简单的方法是每次都用同样的顺序锁定对象,总是在锁定对象Y以前锁定对象X。请参考“资源”部分,那里有关于解除死锁的更详细信息。
- 等待和通知
同步控制了共享数据,但是你可能还需要一个线程等待事件发生然后再访问数据。一个通常的做法是启动一个线程来读取并且处理一个队列里的命令,命令被其他的线程放进来。如果你使用java.util.Vector作为队列,那么一个简单的办法是使用无限循环:
这种循环被成为“忙等待”(busy wait),因为这个线程一直在执行着代码。这样会无效的占用本来可以分配给其他线程用的处理器使用,所以应该尽量避免。最好的是挂起等待(suspended wait),这样线程会处于挂起状态,直到预定的事件发生。挂起状态的线程是不占用处理器时间的。
用作同步监视器的对象,也可以用来作为挂起监视器。深入考虑一下,其实线程同步就是一种特殊的线程挂起事件,因为每个线程都是进入同步等待自己可以访问数据。同步监视器维护了一个线程等待队列,只允许一个线程同时进入同步代码区。
因为每个Java对象都可以作为同步监视器,java.lang.Object定义了三个方法来实现这个基础功能:wait()、notify()、notifyAll()。任意一个线程可以通过调用一个对象的wait方法来挂起自己:
线程只有在锁定这个对象以后才能调用对象的wait()方法(注意代码中,wait方法的调用是在同步代码区里面的)。也需要截获并且处理java.lang.InterruptedException异常,以免出现线程中断。在线程挂起了以后,就默认解除了对对象的锁定。
函数wait()有一个重载,线程可以指定挂起等待超时时间以免进入无限等待。
一旦一个线程挂起了自己,其他线程可以通过调用同一个对象的notify()或者notifyAll()方法来重新唤醒此线程:
同样的,第二个线程也必须先锁定此对象才能调用notify()或者notifyAll()函数。这两个函数的区别仅仅是一个只唤醒一个等待线程,而notifyAll()唤醒所有被此对象wait()挂起的线程。然而唤醒的顺序是不确定的。每一个重新唤醒的线程都必须在等待获取到了此对象锁定以后才能继续执行,因为他们在挂起的时候就已经放弃锁定了。
有了这些知识以后你就可以不用忙等待写一个Worker类了:
这是一个效率很快的版本,因为worker线程只有在队列里有需要处理的数据的时候才占用处理器时间。
需要注意的是,你不能挂起一个不是你的程序创建的线程。系统定义的线程,包括分派用户事件和其他通知的线程等等,往往是由多个程序共享的。挂起一个系统线程可能影响用户界面导致程序锁死,或者影响其他程序获取到一些重要的通知。在这里可以获取更详细的相关信息: Networking, User Experience, and Threads(http://developers.sun.com/mobility/midp/articles/threading/)
- 一个实例
让我们通过一个实例来结束本文。例子中引用了Wireless Messageing API(WMA),一个J2me收发短信包。
一个程序想使用WMA必须注册收到短信的接口。WMA定义了MessageListener接口:
程序需要创建一个对象实现此接口并且注册到WMA子系统。当此程序可以接收的短信到达的时候,WMA子系统会在一个它创建的线程中调用notifyIncomingMessage()方法。WMA的说明中提到,不允许程序在WMA的这个线程中接收或者处理短信,系统仅仅是通知一下程序有一个短信到达而已。程序自己必须在另外的线程中处理短信。下面是我们的处理过程:
MessageQueuer类看起来很复杂,其实不然,它和Worker类其实是一样的结构。Message Queue的构造函数用一个Vector来模拟队列存放收到的短信。这是很典型的代码:
注意MessageQueuer是怎么监听多个连接的。当一个消息到达一个连接以后,MessageQueuer收到它并且把它放到一个MessageQueuer.Entry实例,放到队列中去。另外一个线程在队列的一头等着:
虽然这些代码段里面看不出来,但是需要明确的时候,MessageQueuer类的真正好处在于它允许程序在决定处理哪一个短信之前先检查整个短信队列,比如,程序就可以给某个连接上收到的短信一个优先级。
另外一种实现是写一个短信接收器,每当收到一条短信就启动一个线程。需要注意的是,线程的创建是很耗时的,而且有些设备对可用的线程数目做了限制,所以这种做法对于短时间内涌入大量短信的情况是难以处理的。
- 参考
Sun’s Java Developer web site的Threads and Multithreading(http://java.sun.com/developer/technicalArticles/Threads/)上有更多详细的详细,例如下列书籍:
Concurrent Programming in Java, by Doug Lea
Java Thread Programming, by Paul Hyde
Taming Java Threads, by Allen Holub
- 关于作者:
Eric Giguere(http://www.ericgiguere.com/)是iAnywhere Slutions(Sybase公司的子公司)的一名软件工程师,他主要工作在无线设备的Java技术方面。他在滑铁卢大学(University of Waterloo)获得了计算机科学的学士和硕士学位,有大量的计算方面文章。
标签集合/Tag clouds
C++
Symbain
轻松汇编
算法
论文学习
资治通鉴
Delphi
编程之美
Poco
MFC
Linux
IFC
知乎
汇编
数据分析
交叉编译
poco
j2me
android
XML
Java
DTD
飞信
零宽断言
诺基亚
联系人
编程
真值表
池西木
正则表达式
多线程
命令行
优化
stream
configure
cmake
VIM
UiAutomator
TDD
Symbian
Sqlite
SourceInsight
Python
MPAndroidChart
Kotlin
Flutter
Dokka
Chatgpt