Java基础之并发编程(二)
一、进程与线程
计算机系统有进程与线程两个概念。进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源调度和分配的基本单位,其在执行过程中有自己独立的内存空间,一个进程可以启动多个线程,且这多个线程可以并发执行。线程(Thread)是进程中的一个执行流程,有时也被称为“轻量级进程”(LightWeight Process,LWP),一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合及堆栈构成。另外,线程是进程中的一个实体,是被CPU调度的,线程有自己的堆栈和局部变量,没有独立的地址空间,但可与其他线程共用所在进程的地址空间。
一个进程崩溃后,在保护模式下不会对其他进程产生影响,但一个线程死掉就会整个进程死掉,所以多进程的程序要比单进程、多线程的程序健壮。当然,多个进程之间进行切换时要耗费较多的资源,效率相对低一些。对于一些要求多项操作同时进行又要共享某些变量的并发操作,只能选择多线程。并行使用一些线程通常是在实现算法时的自然反应,而且实际中的算法也往往由一系列可以并发执行的任务组成。但需要注意的是,使用大量的线程将引起过多的上下文切换,最终反而影响了性能。
二、线程属性
1.线程优先级
在Java中,每一个线程都有一个优先级。默认情况下,线程继承它的父线程的优先级。线程有3个典型的优先级:MIN_PRIORITY(0)、NORM_PRIORITY(5)、MAX_PRIORITY(10)。线程的等级可通过setPriority()方法进行设置(0~10范围内)。每当线程调度器有机会选择新线程时,它会首先选择优先等级最高的。在Linux的JVM中优先级被忽略,故不要将功能的正确性依赖于优先级。
2.守护线程
可通过
t.setDaemon(true);
将线程转换为守护线程。守护线程的唯一用途是为其他线程服务,比如计时线程。当只剩下守护线程时,虚拟机就会退出。
三、线程同步
1.竞争条件
当两个或两个以上的线程需要对同一对象进行修改,根据各线程访问数据的次序,可能会产生“讹误”的对象,这样的情况通常称为“竞争条件(Race condition)”。
2.锁对象
在上述的“竞争条件”中,如果能够保证线程在失去控制权之前完成运行,那么该“讹误”状态将不会出现。
有2种机制可避免代码块受并发访问的干扰——synchronized关键字与ReentrantLock类。synchronized关键字自动提供一个“锁”以及相关的条件,对于多数需要显式锁的情况非常便利。
使用ReentrantLock保护代码块的结构如下:
myLock.lock(); //a ReentrantLock Object
try{
critical section
} finally {
myLock.unlock(); //make sure the lock is unlocked even if an exception is thrown
}
这一结构可确保任何时刻只有一个线程能够进入临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,他们被阻塞,直到第一个线程释放锁对象。此处需要说明:把解锁操作放在finally子句之内是至关重要的,因为若处于临界区的代码抛出异常,锁必须被释放,否则其他线程将永远阻塞。
3.条件对象
有时候线程进入了临界区却发现对对象的执行条件并不满足,这时要使用一个条件对象(也被称为条件变量,conditional variable)来管理已经获得了一个锁但是不能做有用工作的线程,即先阻塞并放弃锁(排他性访问),等待其他线程执行该以使其满足本线程执行条件。一个锁对象可以有一个或多个相关的条件对象。
对比一下这两个线程。调用await()方法的线程将进入该条件的等待集。当锁可用时它并不能马上解除阻塞,而是处理阻塞状态直到另一个线程调用同一条件上的signalAll()方法为止。signalAll()方法将重新激活所有因这一条件而等待的线程。当这些线程从等待集当中移出时,它们再次成为可运行的,线程调度器将再次激活它们,这时它们将试图重新进入该对象。一时锁变成可用的,它们中的某个将从await()调用中返回从而获得该锁并从被阻塞的地方继续执行。
此处需要注意一下死锁(deadlock)现象。前面提到线程进入await()调用之后需要其他线程调用signalAll()方法才可能被重新激活,没有办法激活自身,如果所有线程都被阻塞,那么该程序就挂起了。调用signalAll()的时机最好是在对象的状态有利于等待线程的方向改变的时候。调用signalAll()方法不会立即激活一个等待线程,而是仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。
小结:Lock和Condition对象
1)锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码;
2)锁可以管理试图进入被保护代码段的线程(lock()、unlock()方法);
3)锁可以有一个或多个相关的条件对象;
4)每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程(signalAll()、signal()方法)。
4.synchronized关键字
Java中的每个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁,用代码表示为:
public synchronized void method(){
//method body
}
等价于
public void method(){
this.intrinsicLock.lock();
try{
//method body
} finally {
this.intrinsticLock.unlock();
}
}
可以简单地将方法声明为synchronized,而不是使用一个显式的锁。当锁被释放时,对共享变量的修改会写入主存;当获得锁时,CPU缓存中的内容被置为无效。编译器在处理synchronized方法时是在该方法内封闭进行的,从而避免了代码重排导致的问题。
内部对象锁只有一个相关条件。wait()方法添加一个线程到等待集中,notifyAll()/notify()方法解除等待线程的阻塞状态。即方法wait()和notifyAll()分别等价于
intrinsicLock.lock();
intrinsicLock.unlock();
注:wait()、notifyAll()、notify()方法是Object类的final方法,Condition的方法被命名为await()、signalAll()、signal()以使它们不发生冲突。
每个对象有一个内部锁,该锁有一个内部条件。由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait()的线程。将静态方法声明为synchronized也是合法的。如果调用这种方法,该方法将获得相关的类对象的内部锁,此时没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
内部锁加条件存在一些局限:
1)不能中断一个正在试图获得锁的进程;
2)试图获得锁时不能设定超时;
3)每个锁仅有单一的条件,这可能是不够的。
实际编程选用Lock与Condition还是同步方法可参考如下的建议:
1)最好既不使用Lock与Condition,也不使用synchronized关键字,在多数情况下可能使用java.util.concurrent包中的阻塞队列来同步完成一个共同任务的线程;
2)需要加锁时尽量使用synchronized关键字,这样可以简化代码结构并减少出错的几率;
3)特别需要时才使用Lock与Condition。
5.监视器
通用的监视器有如下特性:
1)监视器是只包含私有域的类;
2)每个监视器类的对象有一个相关的锁;
3)使用该锁则对所有的方法加锁。换句话说,如果客户端调用方法obj.method(),那么对象的锁是在该方法调用时自动获得,并且在该方法返回时自动释放。由于所有的域都是私有的,这样就可以保证当一个线程对对象进行操作时,没有其他线程能访问该域。
4)该锁可以有任意多个相关条件。
Java中的每一个对象都有一个内部锁和内部的条件。如果方法使用synchronized关键字声明,那么其表现得就像是一个监视器方法。通过await()、notifyAll()、notify()方法来访问条件变量。然而Java对象与通用监视器有以下不同,使得线程的安全性下降:
1)域不要求必须是private;
2)方法不要求必须是synchronized;
3)内部锁对客户是可用的。
6. Volatile域