【Java基础】第九章 并发控制

#Java进程和线程都是运行时概念,亦即进程和线程不仅仅对象一段代码,而且还包含一些其他的附属资源,更重要的是他们应该能被调度到cpu中运行。从另一方面讲,一段可运行的代码可以对应多个不同的线程,在java中,可被规划为线程运行的代码有一些特殊要求,这段代码必须位于一个特定接口的run方法中。

#进程和线程区别:

每个运行程序都会创建一个进程,这个进程有独立的地址空间。每个进程必须包括至少一个线程,进程中创建的第一个线程叫做主线程。主线程可以创建更多其他线程,这些线程同属于一个进程,因此他们共享同一个地址空间,亦即他们之间可以通过内存直接交换数据。

#在java内存模型中,有两个重要的结构—堆区和栈区。所有的引用类型的示实例都存在堆区,而局部(local)变量都存放在栈区中。线程在执行时,每调用一个新的方法就会在栈区添加一个调用帧(在java中也成为frame),同时在其中分配此方法需要的局部变量的空间。在返回时,调用帧和分配的空间同时被回收。由此可以清楚地看出栈区是和方法调用相关的一个结构,从栈区可以获取到执行线程的方法调用的顺序,需要强调的是,方法返回后,释放的内存会被下一个调用的方法使用。

同一个进程中的多个线程共享堆区,因而可以通过堆区交换数据数据。但是对于共享数据的同时读写可能导致非预期行为的产生,这就要考虑访问冲突的问题,这在并发程序设计中需要仔细考虑。每个线程的栈区是独享的,因此栈区中的数据是多线程安全的,不会发生访问冲突。再一次明确,栈中引用类型的句柄虽然存储在栈区,但是它指向的是堆区的数据,因此也有可能发送访问冲突。

如下图所示,

带阴影的方框是调用帧,局部变量就存储在其中,凉饿线程共享堆区,箭头表示程序执行的流程。每个方法中的代码都可以访问堆区中的共享数据。同一个线程中的代码是顺序执行的,但是多个线程并发时,不同线程之间的方法运行的顺序是不确定的。以上图为例,线程一种的方法运行的顺序时A,B,C,B,线程二种的运行方法顺序时B,C,B,A,在同一个线程种,这个顺序再编程时就确定下来了。但是线程一中的方法c和线程二种的方法c执行的先后顺序就不能确定了,这依赖于线程的调度情况。上面简单以方法为例子说明了调度的不确定性,在实际情况中调度的切换边界时机器指令,既有可能线程一的方法C执行了一部分,然后调度器就让线程二的方法C运行了,然后线程二的方法C还没运行完,又开始执行线程一的方法C的余下代码。而且这个顺序是不确定的,可能在多次运行时顺序每次都不一样。

多线程编程引入了一个不同的编程视角,在多线程程序中,数据的访问可能不是顺序的,程序各部分的交互更为复杂。同时,程序甚至不是”确定“的,同一个程序对于同样的输入(运行的系统环境可能会有所不同)有可能会产生不同的结果。

#java多线程的概念

现代操作系统都支持多线程模型,java也要求宿主系统(host system)支持多线程。

java在此之上提供了一个语法上的封装。这种封装包含两个含义:第一:java在各个宿主系统上使用同样的多线程编程模型;第二,具体的实现委派给了宿主系统的多任务管理模块。因而有如下两个性质:第一,java线程功能集是各个操作系统中的最小功能集;第二,java线程的性能和表现在各个操作系统上存在微小的差异。在程序运行时可以看到,java程序创建一个线程后,在宿主系统中也生成了一个对应的线程。从这一点看,在不同的系统中,java程序可能有不同结果。但是java已经屏蔽了绝大部分的不一致性,在通常情况下不需要刻意注意这点,想了解更深入的理论可以查阅相关资料。本章主要在语法和使用方法上介绍java的多线程模型。

Java语言中多线程的作用:

①提高UI的响应速度。对某些计算型任务,计算时间比较长,如果在计算时不能相应UI的事件会降低程序的可用性。

②提高硬件的资源利用率。目前多核芯片开始普及,只有多线程程序才能有效地利用多核芯片的多个处理单元。

③隔离高速硬件和低俗硬件。网络的速度相对于CPU的吞吐量慢了几个数量级,多线程可以提高系统效率。

④解决本质上时并发的问题。如web访问等。

多线程模型在UI框架中十分常见。在JAVA中,swing组件就包括一个独立的UI线程,此线程负责更新用户界面,其他的线程都不能更新用户界面以避免数据访问冲突。在编程中,为了避免阻塞UI的响应,一些计算量大的任务一般都需要在一个新的线程中运行,下面例子说明了具体使用方法:

...
butons.addActionListener(new ActionListener(){
        public void actionPerformed(ActionEvent event){//UI线程中运行
        Thread thread=new Thread(new Runnable(){   //构建一个新线程
            public void run(){           
                 //进行计算
            });
            thread.start();
        }
};
....
            
        
        
        

该例简要地说明了在UI调度中运行一个新线程的方法,其中一些线程相关的知识在后面讲解。目前只需要了run方法中的代码不是在UI线程中运行就可以。这样UI线程可以继续处理用户的输入和图形界面的更新,新线程独立完成计算任务,计算完毕后可以通过某种方法把信息传递给UI线程。

#线程的创建

线程创建的核心是Thread类。

继承关系:是Object的直接子类。

常用构造方法:

public Thread():默认构造方法,主要是在继承时使用。

public Thread(Runnable target):提供可运行的代码用于线程运行

常用成员方法如下:

多线程的第一步就是创建线程。创建线程的本质是构造一个Thread实例,实例中的run方法由用户定义,run()方法中的代码就是用户线程运行的实际代码。在获取到Thread实例后,调用start()方法启动线程。start方法会实际分配和线程运行相关的操作系统资源。在调用start方法前,此实例只是一个普通对象,调用后,他就会对应于操作系统中的一个线程,同时分配系统资源,如栈结构,线程ID等。

如何创建Thread实例?

①继承Thread类创建线程实例方式:直接继承Thread类并重载run方法,然后使用此继承类创建一个新的Thread实例。

第一步:扩展Thread类

第二步:用希望的执行代码实现run方法

public class 类名 extends Thread{

    public void run(){

               //需要以线程方式运行的代码

      }

}

第三步:通过new关键字实例化该类的一个新对象(一个新线程)

new 类名();

第四步:调用start()方法启动线程。

继承Thread类构造线程实例:

class PrimeThread extends Thread{
        long minPrime;
        PrimeThread(long minPrime){
            this.minPrime=minPrime;
        }
        public void run(){
            //先进性行比minPrime级别高的计算
            .....
        }
}

//启动代码
PrimeThread p=new PrimeThread(143);
p.start();

此方法缺点:若一个类已经继承一个类,则无法继承Thread

②使用Runnable接口创建线程实例的方式:在java.lang包中定义了Runnable接口,其中只有一个run()方法,可以在程序中实现该接口并重写该方法而构造出线程实例。这种方法更通用。这种方式涉及Thread类的构造方法Thread(Runnable target)步骤如下

第一步:实现Runnable接口

第二步:用希望的执行代码实现run方法。

public class 类名 implements Runnable{

    public void run(){

        //需要以线程方式运行的代码

     }

}

第三部:new实例化该类的一个新对象(一个Runnable对象),作为Thread()构造方法的参数,用来生成新的线程体对象。

new Thread(new 类名())

第四步:调用start()方法启动线程。

实例:

class PrimeRun implements Runnable{
        long minPrime;    
        PrimeRun(long minPrime){
            this.minPrime=minPrime;
        }
        public void run(){    
            //先进行比minPrime级别高的运算
            .....
        }
}
//启动代码
PrimeRun p=new PrimeRun(143);
new Thread(p).start();

线程运行后,唯一正确退出线程的方法式从run中退出,包括调用retrun或者抛出一个异常。其他强制线程结束的方法可能导致一些数据处于不正确的状态。在Java类库中,强制结束线程的方法为了保证兼容性还保留着,但是已经被标注为@Deprecated,即不推荐使用。

#线程的生命周期

时刻记住线程是一个运行时概念。线程的生命周期是指线程从创建到销毁的全过程,包括以下几种状态:

出生态(Born)/新线程(New Thread):一个新线程实例刚刚构建,还没有调用start()方法分配线程资源。

就绪态(Ready)/可运行态(Runnable Thread):调用start()方法后,线程已经获得除CPU以外的所有资源,在线程就绪队列中,等待分配CPU资源。

运行态(Running):正在运行的线程。此数目最大值一般为系统的CPU内核数

阻塞态(Blocked):线程在申请等待获取一个锁;

等待态(Waiting):线程调用wait()方法在等待一个特定的事件发生,接收到notify(),notifyall(),或interrupt()方法将退出等待态。

休眠态(Sleeping):线程调用sleep()方法休眠一段指定时间。此时线程不会释放任何锁资源。当休眠时间到或接收到interrupt()方法将退出休眠态转到就绪态等候运行。

死亡态(Dead)/终止态(Terminated):线程已经退出执行,一般是run方法执行完毕。

下图是各状态转换关系:

程序实例:

class LifeCycleThread extends Thread{
        private Object lock;
        public void run(){
            synchronized(lock){
                //进行其他计算工作
                lock.wait();
            }
        }
        public static void main(String args[]){
            LifeCycleThread thread=new LifeCycleThread();
            thread.start();
        }
}

程序说明:当程序运行时,main()运行在主线程中。第10行代码新建了一个线程对象thread,此时线程的状态为出生态。第11行代码调用了start()方法,此方法时线程对象的一个实例方法,此时操作系统会新建一个线程,为方便称为LifeCycle线程,start()方法执行完毕后线程处于就绪态。当获得CPU资源后,lifeCycle线程被调度运行,运行的具体操作就是第三行的run方法的内容,此时出入运行态。第四行申请lock对象的锁,如果不成功,进入阻塞态;如果成功,LifeCycle线程称为lock对象锁的拥有者,继续向下运行,关于synchronized申请同步机制后面介绍。第六行调用锁对象的wait方法,进入等待态,需要有其他的线程调用notify()方法后才能退出等待态。到第七行代码,同步区域结束,释放lock对象的锁。第八行代码run方法结束,系统自动调用return方法,进入终止态。

#线程优先级

各个线程在CPU中的调度时参照优先级来进行的。当线程都处于就绪态时,优先级越高的线程获得的CPU时间也就越多。具体的调度算法依赖于Java系统所在的宿主操作系统。

java线程的优先级分为10级,最高优先级MAX_PRIORITY为对应值10,最低优先级MIN_PRIORITY对应值为1,默认优先级NORM_PRIORITY对应值为5.这些优先级和宿主操作系统的优先级有一个对应关系,在不同的操作系统中有所不同,但他们的映射关系都是正相关,即在Java中优先级越高,在宿主操作系统中的优先级也越高。当Java线程被创建时,该线程从父线程中继承优先级。线程被创建后,可以改变线程的优先级。设置优先级使用setPriority(int newPriority)方法,获取线程的优先级使用getPriority()方法。

多线程调度是一个复杂过程,调度结果不仅仅依赖于设置的优先级,也和宿主操作系统的环境和调度算法有关。甚至可能出现在优先级设置高而吞吐量反而低的情况,这种情况可能发生在低负载的Windows宿主环境中。Windows在线程调度中会优先调度高优先级的线程,但分配较短的时间单位以便快速切换任务。在低负载的系统中,其他任务可能很少需要CPU,频繁的切换运行线程会带来很大的额外开销-包括线程切换开销、CPU中的缓存命中率降低等。在这种情况下,运行线程设置低的优先级反而获取到更长的时间片,就算运行时间稍有降低,但运行性能反而更高。这个特例说明,在多线程编程和调度上需要考虑的事情很复杂。

线程有一个特殊的属性,Daemon,缺省值为false,如果此值为真,当其他非Daemon线程都处于终止态时,整个进程结束。也就是说Daemon线程是后台监控线程,他有可能在任何运行点被终止。与Daemon相关的两个方法:setDaemon()方法用于设置Daemon的值,缺省为false;isDaemon()获取线程的Daemon值

Daemon(后台)线程在其他线程结束后,自动结束。

#线程之间的协作

在多线程编程中,多个同时运行的线程一般需要协作来完成一个共同的任务。协作意味着需要通过某种方式共享信息。可以使用两种方式实现:基于消息和基于共享内存。Java采用共享内存的方式,这种方式实现简单且效率高,但留给程序员的任务相对较多。因为线程可以共享堆中数据,多个线程可以通过访问这些共享数据来协作。这也引出了一个多线程编程中重要的问题:对于同一个数据的访问可能导致潜在的冲突,就是”访问冲突“

冲突发生的条件和种类:

①部分更新问题

假设现在有一个类实例,此实例有多个属性,如果两个线程同时试图更新此类的多个属性,就有可能导致业务类的部分属性由一个线程更新,而另一部分属性由另一个线程更新。可能是A的id对应成了B的姓名。

②读写不一致问题

//线程一

int step=1;

shareInt=shareInt+step;

//线程二:

int step=2;

shareInt=shareInt+step;

若shareInt为堆中共享变量,step为栈中局部变量,假设shareInt初始值为1,显然执行完毕后正确值应该为4.若按下列顺序执行线程:

线程一执行了加法,得到结果2,在存储到shareInt之前,线程二也执行了加法,得到结果3;这样,最终结果可能为2或3,而不是4

注:冲突发送的最主要原因是,在这个算法设计中,读写在业务逻辑中是紧密绑定的,写操作时要保证上次读的数据没发生变化。

#同步区域

如何解决上述问题?Java选择非常直接和简单的方法来避免这种情况的发生。需要强调的时java只提供了一种语言上的支持,程序必须根据根据需要恰当地使用这种机制来避免访问冲突。并发冲突导致的bug很难调试,察觉到错误时,程序可能不在访问冲突点附近了,同时错误发生的场景很难再现。因为每次并发的顺序不确定。

java提供的机制是,通过标记一段代码或者一个方法为synchronized(同步)来设定同步区域。在这段代码的前部,编译器会自动插入一段代码,此代码申请获取一个对象的锁,此锁在同一时刻只能有一个拥有者。这样就保证可能冲突的代码不会同时进入,代码的执行结果顺序就和顺序执行的结果一致了。

可以用两种方法标定同步区域,一种是在方法前面:

synchronized RetrunType methodName(parameterList){

    //method body

}

另一种方法是用语句块来标定:

synchronized(object){

    //statements

}

第一种方法隐式地使用了this作为synchronize的锁对象,如果方法是static的,很显然没有this对象,此时使用此方法所在的类对象。这个条件意味着所有标记synchronized的方法都不能同时进入,既不能同时在多线程种执行多个不同的synchronized方法。如果有多个逻辑上需要隔离的同步区域,显然需要多个锁对象,这时应该显式声明锁对象的方法,而不是使用this作为锁对象。下面是隐式方法的等价方法:

ReturnType methodName(parameterList){

    synchronized(this){

    //statements

}

需要指出的是,这只是逻辑上等价的方法,在虚拟机上他们的实现还是有区别的,但是性能上基本一致。

了解下这种机制是怎么实现的:

在java语言中为每个引用对象(Object)都设置了一个锁(monitor),锁同一个时刻只允许一个线程拥有,当程序控制流进入同步区域时,将对象锁住,其他线程不能获取锁,因而不能执行,从而实现线程同步。当拥有锁对象的线程执行同步语句之后,其他线程才能获取对象的锁。每个锁对象都有一个申请获取锁的队列,很显然,此队列中的线程都处于阻塞态。当锁重新有效时,选择哪一个线程获取到锁是不确定的,这依赖于具体的实现,而且也不一定保证公平(先申请先获取)。获取到锁的线程转入就绪态等待CPU调度运行,其他的线程继续等待。

为了保证不出现部分更新问题,还有一个隐含假设:关键区域的代码需要完整执行。如果执行了部分代码时线程被强行中断,那么对象的状态同样可能处于一个非法的状态。这种情况可能发生在代码的任何位置,事后的检查和补救几乎是不可能的。这就是java废除强行中断线程的原因。

使用第二种方法解决读写不一致问题:

//线程一

synchronized(lock){

    shareInt=shareInt+1;

}

//线程二

synchronized(lock){

    shareInt=shareInt+2;

}

在这个例子中,lock对象是共享的锁对象。shareInt是共享的变量。在进入可能冲突区域前,这两个线程需要申请获取到lock对象的锁,因此,他们不能同时执行冲突代码。通过这种方法,就能避免读写不一致的问题。需要说明的是,锁对象是由线程来申请和获取的,因此同一段同步代码如果在多个线程中运行,也需要在这些线程中分别申请锁。

#协作机制

多个线程间如何高效协作来共同完成一个任务?

一个最简单低效的方法就是通过在共享内存中设置一些标记来完成,某个事件发生后,修改标识,其他线程查询此标识来进行下一步动作。这种方法最大的问题就是效率太低,线程需要不断地进行查询工作。

为此java提供了一种通知机制来提高效率,使用此机制可以方便地进行线程之间的通知工作。

事件通知机制设计以下两个方法簇:

wait():在一个引用对象上等待接收通知。

notify():通知此对象上的等待线程事件已发生,选择一个等待态线程退出等待态。

这两个方法都是在Object类中定义,因此任何引用对象都可以作为锁对象,并提供事件通知机制。

wait方法有以下三个重载方法:

public final void wait():一直等待

public final void wait(long timeout):等待指定长度时间,单位为毫秒;

public final void wait(long timeout,int nanoe):等待(timeout毫秒+nanos纳秒)时间,

调用wait()方法时,当前线程必须拥有该对象的锁,也就是在被此对象的synchronized包括的同步语句块中。当线程调用wait()方法后即进入等待态,同时释放对象的锁。当发生以下条件时,线程重新进入就绪态:

①其他线程调用了等待对象的notify()方法,而且此现场被选中为苏醒线程。

②其他线程调用了等待对象的notifyAll()方法。

③其他线程调用了处于等待态的线程的interrupt()方法。

④如果调用的是带参数的wait方法,需等待指定的时间。

在某些特定情况下,可能发生所谓的“可疑苏醒”,亦即什么情况都没发生,线程自动苏醒过来。因此,一个良好的习惯是,编写的程序在苏醒后检查条件是否满足。下面是一个简要的说明代码。

synchronized(obj){

    while(<condition does not hold>){

        obj.wait(timeout);

       //条件满足,执行后续动作

}

线程转入等待态时,锁已经被释放。从等待态被唤醒后,需要重新竞争对象的锁,也就是说,没有获取到锁,就会转换为阻塞态。这种机制看起来很难理解,但是wait()方法是在‘安全区域调用’的,当唤醒时,此线程还位于此安全区域中,因此必须重新获取锁。因此申请锁的位置有两个,一个是使用synchronized关键字申请,一个是在wait方法返回时系统隐式申请获取锁。过程如下:

多个线程在同一个锁对象中调用了wait()方法后,唤醒时按照什么顺序呢?java规范对于此问题的说明是按照任意顺序,即在不同的宿主系统中唤醒的顺序是不确定的。在编写程序时,程序逻辑不能依赖于特定的唤醒顺序。

下例说明了多线程协作机制:

public class BoundedBuffer{
    private Object lock=new Object(); //锁用来保护关键区域
    private Object emptylock=new Object();  //用来通知是否为空的线程
    final Object[] items=new Object[100];
    int putptr,takeptr,count;
    
    public void put(Object x) throws InterruptedException{
        synchronized(lock){
            while(count==item.length)
                lock.wait(); //等待take线程取走数据
            items[putptr]=x;
            if(++putptr==item.length)
                putptr=0;
            ++count;
            synchronized(emptyLock){
                emptyLock.notify();  //通知take线程
            }
        }
    }
    
    public Object take() throws InterruptedException{
        synchronized(lock){
            while(count==0){
                synchronized(emptyLock){ //如果为空,等待
                    emptyLock.wait();
                }
            }
            Object x=items.[takeptr];
            if(++takeptr==items.length)
                takeptr=0;
            --count;
            lock.notify(); //通知put线程
            return x;
        }
    }
}

程序说明:在这个例子中有一个锁对象lock,此对象用来保护同步区域,避免部分更新问题,同时也管理“队列满事件”的通知。emptyLock仅仅是一个通知对象,管理“队列空事件”的通知。BoundedBuffer类中有一个大小固定的对象数组用来存储数据,一个写入“指针”,一个读取“指针”。put()方法首先获取到锁lock,然后在第10行检查缓存是否已经满了;如果是,等待数据被读取。第12行代码处,可以确定数据没有填满,接下来将填充数据。可能有提取线程在emptyLock上等待通知,因此需要发生通知。take()方法首先检查数组是否为空,如果为空,则等待。因为可能发生“可疑苏醒”,因此使用了while检查条件是否满足。同样在获取数据后,需要通知可能的等待放置数据的线程。

此例能在多线程环境下正常运行,且在各种调度的情况下能保证数据的一致性,同时通过通知机制避免了忙等待。不足之处在于,emptyLock对象仅仅用来进行通知,并不需要使用它来保护同步区域,但为了通知需要获取到它的锁对象,稍显重复。

#死锁

死锁是指处于等待态或阻塞态的线程之间又相互依赖的等待现象而导致他们都不可能变为就绪态的一种状态。很显然,这种行为是需要避免的。Java语言本身对死锁的检测和避免没有提供任何支持,程序员需要通过良好的设计来避免死锁的发生。Java语言一般用来信息处理,很少用来管理外部复杂设备。因此,同步区域和通知机制一般不会太复杂,可以通过几个设计原则来有效避免死锁发生:

①逻辑隔离。每个逻辑上独立的同步区域都是用各自的锁对象而不是共享同一个锁对象。

②尽快释放。使用完毕后尽快释放锁对象。

③按顺序申请。对于需要使用多个锁对象的区域,所有的申请都按照固定的顺序。

注:调用start()方法后,线程不会立刻执行,可能在等待态,main线程执行完再执行。

不同线程同步代码块要使用同一个锁(通常是this)

synchronized修饰静态函数使用类锁(类的字节码文件锁),修饰代码块时,对应参数为类名.class.

同步中嵌套同步,但锁不同,会造成死锁。

#线程间通讯:多个线程操作同一资源,操作动作不同。

#JDK5.0提供多线程升级解决方案,将s'y'nchronized同步换成了Lock显示锁操作,将Object中的wait,notify,notifyall用condition对象的await,signal,signalall方法替换,concition对象可由lock对象得到。优点在于一个lock锁可以生成多个condition对象,在线程通讯中可以用一个锁唤醒对方线程。

#stop方法过时,如何停止线程?

只有停止run方法,多线程中run方法中代码通常是循环执行,只要控制循环,即可停止线程

猜你喜欢

转载自blog.csdn.net/liugf2115/article/details/86554017