Java并行程序基础

进程(Process)是计算机中的程序关于某些数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程是线程的容器,程序是指令、数据和组织形式的描述,进程是程序的主体。进程中可以包含若干个线程,线程是不可见的。

进程与线程的关系:简单的说,进程是一个容器。线程是轻量级的进程,是程序执行的最小单位,线程间的切换与调度成本远小于进程。

一、线程的生命周期

线程的生命周期分为5个状态:新建状态、就绪状态、运行状态,阻塞状态、死亡状态。

线程的状态定义在Thread.State枚举类中:

线程的状态定义
线程的生命周期流程

NEW状态表示刚创建的线程,此时线程还未开始执行,等到线程调用start()时,表示线程开始执行。当线程执行时,处于RUNNABLE状态,表示线程所需的一切资源就绪,如果线程在执行过程中遇到synchronized块,就会进入BLOCKED阻塞状态,此时线程会暂停,直到获取到请求的锁。WAITING和TIMED_WAITING都表示等待状态,区别是WAITING会进入一个无时间限制的等待,TIMED_WAITING会进行有时间的等待。通过wait()等待的线程在等待notify(),通过join()等待的线程会等待目标线程的终止。一旦等到了期望的事件,线程就会再次执行,进入RUNNABLE状态。当线程执行完毕,会进入TERMINATED状态,表示结束。

注意:线程从NEW状态开始后则不能再回到NEW,处于TERMINATED的线程也不能再回到RUNNABLE状态。

线程状态定义:

新建状态(new):指新建了一个线程对象。Thread t1 =new Thread();

就绪状态(Runnable):当线程对象创建后,该线程对象自身或者其他对象调用了该对象的start()方法。该线程就位于可运行池中,等待获取cpu的使用权,因为在同一时间里cpu只能执行某一个线程。

运行状态(Running): 当就绪状态的线程获取了cpu的时间片或者获取了cpu的执行时间,这时就会调用该线程对象的run()方法,从就绪状态进入运行状态。

阻塞状态(Blocked):阻塞状态就是线程因为某种原因暂时放弃了对cpu的使用权,暂时停止运行。直到线程再次进入就绪状态,才有机会转到运行状态。阻塞状态分为三种情况:

    1)等待阻塞:运行状态的线程调用了wait()方法后,该线程会释放它所持有的锁,然后被JVM放入到等待池中,只有等其他线程调用Object类的notify()/norifyAll()方法时,才能重新进入到就绪状态。

    2)同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,JVM就会把该线程设置为阻塞状态,一直到线程获取到同步锁,才能转入就绪状态。

    3)其它阻塞:运行的线程在执行sleep()、join()方法、发出了I/O请求,JVM就会把该线程设置为阻塞状态,当sleep()状态超时、join()等待线程终止或超时、I/O处理完毕时,线程重进转入到就绪状态。注意sleep()方法和wait()不同,sleep不会释放自身所持有的锁。

死亡状态(Dead):当线程执行完或因异常退出了run()的执行,该线程的生命周期就结束了。

二、线程的基本操作

1、新建线程

创建线程只需要使用new创建一个线程对象即可:

Thread t1 =new Thread();

t1.start();

注意:启动线程要使用start(),而不能使用run(),因为run()只会在当前线程中串行执行run()中的代码。

使用匿名内部类创建线程
使用Runnable接口方式创建线程

推荐使用实现Runnable接口方式创建线程,并将该实例传入Thread,这样避免重载Thread.run(),因为其也是直接调用内部的Runnable接口。

2、终止线程

一般来说,线程在执行完毕后会自动结束,但也会存在一些线程不会正常终止。Thread提供了stop()方法(标记为废弃的方法@Deprecated)可以用于终止线程,但是不推荐使用,原因是它会是强行把执行到一半的线程终止,可能会引起某些数据不一致的问题。

stop()在结束线程时,会之间终止线程,并且会立即释放该线程所持有的锁。这些锁是维持对象一致性的,如果此时线程写入正在写入数据,强行终止会将对象写坏。同时由于锁已经释放,其他等待该锁的读线程就会读到了这个不一致的对象,导致数据错误。

废弃的stop()方法

3、线程中断

线程中断是一种重要的线程协助机制,表面上看中断就是让目标线程停止执行的意思,实际并非完全如此。线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程需要退出执行。但是接到通知的目标线程如何处理,完全由它自行决定。若线程中断后,线程立即无条件退出,则又会遇到stop()的问题。

线程中断的方法定义

interrupt()是一个实例方法,它通知目标线程中断,即设置中断标志位,表示当前线程已被中断。static interrupted()也是用了判断当前线程的中断状态,但它会同时清除当前线程的中断标志位状态。isInterrupted()也是实例方法,通过检查中断标志位来判断当前线程释放被中断。

中断示例:虽然对t1进行了中断,但在 t1中并未对中断进行处理,即使线程是中断状态,实际这个中断不会发生任何作用  
示例:让线程t1在中断后退出  

Thread.sleep() 定义:

public static native void sleep(long millis) throws InterruptedException;

sleep()会让当前线程休眠若干时间,并会抛出一个InterruptedException异常,程序必须捕获并处理它。

线程sleep()示例

sleep()方法由于中断而抛出异常,此时它会清除中断标记,若不加处理,那么在下一次循环时就无法捕获这个中断,所以在异常处理中需要再次设置中断标记位。

4、等待(wait)和通知(notify)

为了支持多线程之间的协助,JDK提供了wait()和notify()方法。它们并不在Thread类中,而是在Object类中,即任何对象都可以调用这两个方法。

方法定义:

wait()和notify()方法定义

当在一个对象实例上调用wait()后,当前线程就会在这个对象上等待。比如在线程A中调用了wait(),则线程A就会停止执行并转为等待状态,直到其他线程调用了notify()为止。

wait()和notify()的关系:如果一个线程调用了object.wait(),那么它就会进入object对象的等待队列中,这个等待队列中可能会有多个线程。当object.notify()被调用时,它就会从这个等待队列中随机选择一个线程,并将其唤醒,这个选择是非公平的、随机的。注意,object.wait()并不是可以随便调用的,它须包含在对应的synchronized块中,无论wait()或notify()都需要首先获得目标对象的监视器。wait()方法执行后会释放这个监视器。

wait()和notify()使用示例
wait()和notify()示例结果

注意:wait()和sleep()都可以让线程等待若干时间,区别是wait()除了可以被唤醒外,另外一个区别是wait()会释放目标对象的锁,而sleep()不会释放任何资源。

5、挂起(suspend)和继续执行(resume)

和stop()方法一样,suspend()和resume()都是被标记为废弃的方法,官方并不推荐使用。因为suspend()在导致线程暂停的同时,并不会释放任何资源,若此时其他线程想要服务被它暂用的锁时,都会被牵连导致无法正常运行。直到对于的线程调用了resume(),被挂起的线程才能继续,其他所有阻塞在相关锁上的线程也可以继续执行。如果resume()意外的在suspend()前执行,那么被挂起的线程可能很难有机会被继续执行。更严重的是:它所占用的锁不会释放,可能导致整个系统异常。对于已被挂起的线程,从它的线程状态上看还是Runnable,这会严重影响我们对系统当前状态的判断。

示例:使用wait()和notify()实现suspend()和resume()的功能
示例:使用wait()和notify()实现suspend()和resume()的功能

6、等待线程结束(join)和谦让(yield)

在很多情况下,一个线程的输入可能非常依赖于另外一个活多个线程的输出,此时这个线程就需要等待依赖线程执行结束,才能继续执行。JDK提供了join()方法来实现此功能:

join()方法定义
join()示例

如果不加join()等待AddThread,那么i的值可能是0或者更小的数字。因为AddThread还没开始执行,i就已经输出了。使用了join()后,表示主线程等待AddThread执行完毕,跟着AddThread一起执行,在join()返回时,AddThread已执行完,输出结果10000。

join()的本质是让调用线程wait()在当前线程对象实例上,它让调用线程在当前线程对象上进行等待。当线程执行完后,被等待的线程会在退出前调用notifyAll()通知所有的等待线程继续执行。

Thread.yield():

yield()定义

yield()是一个native的静态方法,它会让当前线程让出CPU。当前线程在让出CPU后,还会进行CPU资源的争夺。

三、volatile与Java内存模型(JMM)

用volatile声明一个变量时,相当于告诉JVM这个变量很可能会被某些程序或线程修改。为了保证这个变量被修改后,应用程序范围内所有的线程都能够感知到这个改动,JVM就必须采用一些特殊手段,保证这个变量的可见性。

根据编译器的优化规则,如果不使用volatile声明变量,那么这个变量被修改后,其他线程可能并不会被通知到,甚至在其他线程中看到的变量修改顺序是反的。

不使用volatile声明变量

以上示例中,ReaderThread线程只在ready=true时,才会打印number,通过ready变量来判断是否打印。在主线程中,启动ReaderThread后再为number和ready赋值,然后期望ReaderThread线程可以看到变化的数据并输出。

在JVM的client模式下,由于JIT并未做足够的优化,在主线程修改ready变量后,ReaderThread线程可以发现这个改动,并退出程序。但在server模式下,由于系统优化的结果,ReaderThread线程无法看到主线程中的修改,导致ReaderThread永远无法退出程序。解决方式是将ready变量用volatile修饰:private volatile static boolean ready =false;

注:可以使用JVM参数-server切换到Server模式。

四、线程组

在一个系统中,如果线程数量很多,且功能分配比较明确,就可以将相同功能的线程放置在一个线程组中。

线程组的使用
运行结果

五、守护线程(Daemon)

守护线程是一种特殊的线程,它是系统的守护者,在后台默默的完成一些系统性的服务,如垃圾回收、JIT编译等。当Java应用内只有守护线程时,JVM就会自然退出。

守护线程示例

上例中线程t被设置为守护线程,系统中只有主线程main为用户线程,在主线程休眠2S后退出,整个程序也结束。如果线程t不被设置为守护线程,在主线程结束后,线程t还会继续执行且永远不会结束。

运行结果

六、线程优先级

线程可以有自己的优先级,优先级高的线程在资源竞争时会更有优势。由于线程的优先级调度和底层操作系统有关联,在不同平台上这种优先级的后果可能无法预测,比如一个低优先级的线程可能一直无法抢占资源而产生饥饿现象。

Java中使用1到10表示线程的优先级,数字越大表示优先级越高,但有效范围在1-10之间。一般可以使用内置的三个静态常量表示:

优先级的静态常量
线程优先级示例
运行结果:大部分情况下高有效级的线程总是先执行完

七、线程安全与synchronized

一般来说,程序并行化是为了获得更高的执行效率,前提是高效率必须保证其正确性。线程安全是并行程序的根本。

Java提供了synchronized关键字来实现线程间的同步。它对需要同步的代码加锁,每次只能有一个线程进入同步块,从而保证线程间的安全性。

synchronized关键字的用法:

指定加锁对象:对给定对象加锁,进入同步代码前需要获得给定对象的锁。

直接作用于实例方法:相当于对当前实例加锁,进入同步代码前需要获得当前实例的锁。

直接作用于静态方法:相当于对当前类加锁,进入同步代码前需要获得当前类的锁。

对给定对象加锁

synchronized除了用户线程同步保证线程安全外,还可以保证线程间的可见性和有序性。它可以完全替代volatile的功能,只是在使用时没有那么方便。由于synchronized限制每次只能有一个线程可以访问同步块,因此无论同步块内的代码如何被乱序执行,只要保证串行语义一致,则执行结果都是一样的。

八、隐蔽的错误

1、无提示的错误

错误示例
运行结果

上例中计算v1与v2的平均值,我们期望的结果是输出1500000000,但是却输出了-647483648,原因是int类型数据溢出了。如果系统逻辑简单的话这类问题查找还是相对容易的,但是如果这种问题发生在一个复杂系统的内部,由于问题很小且没有异常日志信息输出,排查起来可能会非常困难。错误的使用并行,会很容易产生这类问题,它们如幽灵一般难觅踪迹。

2、并发下的ArrayList

ArrayList是一个非线程安全的容器,如果在多线程中使用,可能会导致程序出错。

ArrayList并发示例

以上代码中,t1和t2线程同时向ArrayList中添加数据,期望得到结果20000,但是执行后可能会出现三种结果:

1)程序正常结束,最终得到结果20000,说明即使并行程序有问题也并非每次都会出现。

2)程序出现异常,因为ArrayList在扩容过程中,内部一致性被破坏 ,由于没有锁的保护,另外一线程访问到了不一致的内部结果,导致出现越界问题。如下所示:

3)出现一个非常隐蔽的错误结果,比如打印出了结果:17081。这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常的访问,两个线程同时对ArrayList的同一个位置进行赋值导致的,此时你将得到一个没有错误提示的错误,并且它们也未必是可以复现的。

3、并发下诡异的HashMap

HashMap同样是非线程安全的,当使用多线程访问时,也可能会遇到奇怪的错误。和ArrayList不同的是HashMap的问题可能更诡异。

HashMap并发问题

以上代码用线程t1和t2同时对HashMap放值,若一切正常将得到map的大小19999。而实际上可能会出现三种情况:

程序正常且结果也正确

程序正常但结果不正确,它会输出一个小于结果的数字比如12036

程序永远无法结束

对于前2种可能,它和ArrayList类似。而最后一种情况,可能会觉得有些诧异,当你打开任务管理器查看CPU占用时,就会发现它占用了很高的CPU,如果CPU性能较弱,可能会导致死机。

Java8中已经对HashMap的内部实现做了优化调整,以规避内部可能出现的并发下死循环的问题,即便如此也不要在并发下使用HashMap,而应该使用ConcurrentHashMap。

4、错误的加锁

在多线程同步时,加锁是保证线程安全的重要方式,但前提是加锁必须合理。

计数器加锁示例

以上代码使用线程t1和t2同时对累加i,并且每次对i自增前都需获得i的锁,用来保证i是线程安全的。理论上执行结果将是20000,但实际上并非如此,它可能是一个远小于20000的一个值,why?

要解释此问题需要先从Integer说起,Integer是不可变对象,即对象一旦创建就不能再修改,比如Integer=1,那么它永远都是1,如果想让它是2的话就必须再new一个Integer=2。

反编译后的代码

反编译后的代码行中,使用了Integer.valueOf()方法创建了一个新的Integer对象,并将它赋值给变量i,即i++变成了i = Integer.valueOf(i.intValue() + 1)。

Integer.valueOf()方法定义:

valueOf()方法定义
IntegerCache定义

Integer.valueOf()是一个工厂方法,它会返回一个代表指定数值的Integer实现,i++的本质是创建一个新的Integer对象,并将它的引用赋值给i。由于在多个线程间并不一定能看到同一个i对象,两个线程每次加锁可能都加在了不同的对象实例上,从而导致问题出现。

解决方式是将synchronized (i)改成synchronized (instance)。


--参考文献《实战Java高并发程序设计》

你可能感兴趣的:(Java并行程序基础)