2.实战java高并发程序设计--java并行程序基础

2.1 有关线程你必须知道的事

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

用一句简单的话来说,你在Windows中看到的后缀为.exe的文件都是程序。不过程序是“死”的,静态的。当你双击这个.exe程序的时候,这个.exe文件中的指令就会被加载,那么你就能得到一个有关这个.exe程序的进程。进程是“活”的,或者说是正在被执行的。

进程中可以容纳若干个线程。

线程就是轻量级进程,是程序执行的最小单位。使用多线程而不是用多进程去进行并发程序的设计,是因为线程间的切换和调度的成本远远小于进程

NEW状态表示刚刚创建的线程,这种线程还没开始执行。等到线程的start()方法调用时,才表示线程开始执行。当线程执行时,处于RUNNABLE状态,表示线程所需的一切资源都已经准备好了。如果线程在执行过程中遇到了synchronized同步块,就会进入BLOCKED阻塞状态,这时线程就会暂停执行,直到获得请求的锁。WAITING和TIMED_WAITING都表示等待状态,它们的区别是WAITING会进入一个无时间限制的等待,TIMED_WAITING会进行一个有时限的等待。那么等待的线程究竟在等什么呢?一般来说,WAITING的线程正是在等待一些特殊的事件。比如,通过wait()方法等待的线程在等待notify()方法,而通过join()方法等待的线程则会等待目标线程的终止。一旦等到了期望的事件,线程就会再次执行,进入RUNNABLE状态。当线程执行完毕后,则进入TERMINATED状态,表示结束。

注意:从NEW状态出发后,线程不能再回到NEW状态,同理,处于TERMINATED状态的线程也不能再回到RUNNABLE状态。

2.2 初始线程:线程的基本操作

2.2.1 新建线程

这里要注意,下面的代码通过编译,也能正常执行。但是,却不能新建一个线程,而是在当前线程中调用run()方法,只是作为一个普通的方法调用。[插图]因此,在这里希望大家特别注意,调用start()方法和直接调用run()方法的区别。

注意:不要用run()方法来开启新线程。它只会在当前线程中串行执行run()方法中的代码。

上述代码使用匿名内部类,重写了run()方法,并要求线程在执行时打印“Hello, I am t1”的字样。如果没有特别的需要,都可以通过继承线程Thread,重写run()方法来自定义线程。但考虑到Java是单继承的,也就是说继承本身也是一种很宝贵的资源,因此,我们也可以使用Runnable接口来实现同样的操作。Runnable接口是一个单方法接口,它只有一个run()方法:

2.2.2 终止线程

一般来说,线程执行完毕就会结束,无须手工关闭。但是,凡事都有例外。一些服务端的后台线程可能会常驻系统,它们通常不会正常终结。比如,它们的执行体本身就是一个大大的无穷循环,用于提供某些服务。那么如何正常地关闭一个线程呢?查阅JDK,你不难发现线程Thread提供了一个stop()方法。如果你使用stop()方法,就可以立即将一个线程终止,非常方便。但如果你使用Eclipse之类的IDE写代码,就会发现stop()方法是一个被标注为废弃的方法。也就是说,在将来,JDK可能就会移除该方法。为什么stop()方法被废弃而不推荐使用呢?原因是stop()方法过于暴力,强行把执行到一半的线程终止,可能会引起一些数据不一致的问题.

Thread.stop()方法在结束线程时,会直接终止线程,

并立即释放这个线程所持有的锁,而这些锁恰恰是用来维持对象一致性的。如果此时,写线程写入数据正写到一半,并强行终止,那么对象就会被写坏,同时,由于锁已经被释放,另外一个等待该锁的读线程就顺理成章地读到了这个不一致的对象,悲剧也就此发生。可以使用break终止方法,线程会在适当的时候停止.最直接的方法就是设一个boolean类型的标志,并通过设置这个标志为true或false来控制while循环是否退出

2.2.3 线程中断

      线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程,有人希望你退出啦!至于目标线程接到通知后如何处理,则完全由目标线程自行决定。这点很重要,如果中断后,线程立即无条件退出,我们就又会遇到stop()方法的老问题。

注意:Thread.sleep()方法由于中断而抛出异常,此时,它会清除中断标记,如果不加处理,那么在下一次循环开始时,就无法捕获这个中断,故在异常处理中,再次设置中断标记位。

2.2.4 等待(wait)和通知(notify)

为了支持多线程之间的协作,JDK提供了两个非常重要的接口线程:等待wait()方法和通知notify()方法。这两个方法并不是在Thread类中的,而是输出Object类。这也意味着任何对象都可以调用这两个方法。

那么wait()方法和notify()方法究竟是如何工作的呢?图2.5展示了两者的工作过程。如果一个线程调用了object.wait()方法,那么它就会进入object对象的等待队列。这个等待队列中,可能会有多个线程,因为系统运行多个线程同时等待某一个对象。当object.notify()方法被调用时,它就会从这个等待队列中随机选择一个线程,并将其唤醒。这里希望大家注意的是,这个选择是不公平的,并不是先等待的线程就会优先被选择,这个选择完全是随机的。

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

2.2.5 挂起(suspend)和继续执行(resume)线程

如果你阅读JDK有关Thread类的API文档,可能还会发现两个看起来非常有用的接口,即线程挂起(suspend)和继续执行(resume)。这两个操作是一对相反的操作,被挂起的线程,必须要等到resume()方法操作后,才能继续指定。乍看之下,这对操作就像Thread.stop()方法一样好用。但如果你仔细阅读文档说明,会发现它们也早已被标注为废弃方法,并不推荐使用

不推荐使用suspend()方法去挂起线程是因为suspend()方法在导致线程暂停的同时,并不会释放任何锁资源。此时,其他任何线程想要访问被它占用的锁时,都会被牵连,导致无法正常继续运行(如图2.7所示)。直到对应的线程上进行了resume()方法操作,被挂起的线程才能继续,从而其他所有阻塞在相关锁上的线程也可以继续执行。但是,如果resume()方法操作意外地在suspend()方法前就执行了,那么被挂起的线程可能很难有机会被继续执行。并且,更严重的是:它所占用的锁不会被释放,因此可能会导致整个系统工作不正常。而且,对于被挂起的线程,从它的线程状态上看,居然还是Runnable,这也会严重影响我们对系统当前状态的判断。

2.2.6 等待线程结束(join)和谦让(yeild)

一个线程的输入可能非常依赖于另外一个或者多个线程的输出,此时,这个线程就需要等待依赖线程执行完毕,才能继续执行。JDK提供了join()操作来实现这个功能。如下所示,显示了两个join()方法:

第一个join()方法表示无限等待,它会一直阻塞当前线程,直到目标线程执行完毕。第二个方法给出了一个最大等待时间,如果超过给定时间目标线程还在执行,当前线程也会因为“等不及了”,而继续往下执行。

有关join()方法,我还想再补充一点,join()方法的本质是让调用线程wait()方法在当前线程对象实例上.

可以看到,它让调用线程在当前线程对象上进行等待。当线程执行完成后,被等待的线程会在退出前调用notifyAll()方法通知所有的等待线程继续执行。因此,值得注意的一点是:不要在应用程序中,在Thread对象实例上使用类似wait()方法或者notify()方法等,因为这很有可能会影响系统API的工作,或者被系统API所影响。

Thread.yield()

这是一个静态方法,一旦执行,它会使当前线程让出CPU。但要注意,让出CPU并不表示当前线程不执行了。当前线程在让出CPU后,还会进行CPU资源的争夺,但是是否能够再次被分配到就不一定了。因此,对Thread.yield()方法的调用就好像是在说:“我已经完成了一些最重要的工作了,我可以休息一下了,可以给其他线程一些工作机会啦!

如果你觉得一个线程不那么重要,或者优先级非常低,而且又害怕它会占用太多的CPU资源,那么可以在适当的时候调用Thread.yield()方法,给予其他重要线程更多的工作机会。

Thread.yield() 方法,使当前线程由执行状态,变成为就绪状态,让出cpu时间,在下一个线程执行时候,此线程有可能被执行,也有可能没有被执行。翻译成中文就是让步的意思.

java多线程编程join的作用是等待线程结束,这个作用可以产生很多特定的场景。 
1)A线程中调用B线程的join方法,那么A线程需要等待B线程执行完成后才能完成 
2)主线程中依次调用A线程的join方法,B线程的join方法,可以保证A,B线程顺序执行;

是主线程进入等待状态,子线程在运行,子线程运行完成后会通知主线程继续运行,或者join也可以设置主线程的等待时间,当主线程等待超时时,即使子线程没有运行完,主线程也会开始继续执行,


2.3 volatile与Java内存模型(JMM)

为了在适当的场合,确保线程间的有序性、可见性和原子性。Java使用了一些特殊的操作或者关键字来声明、告诉虚拟机,在这个地方,要尤其注意,不能随意变动优化目标指令。关键字volatile就是其中之一

 当你用关键字volatile声明一个变量时,就等于告诉了虚拟机,这个变量极有可能会被某些程序或者线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能够“看到”这个改动,虚拟机就必须采用一些特殊的手段,保证这个变量的可见性等特点。

比如,根据编译器的优化规则,如果不使用关键字volatile声明变量,那么这个变量被修改后,其他线程可能并不会被通知到,甚至在别的线程中,看到变量的修改顺序都会是反的。一旦使用关键字volatile,虚拟机就会特别小心地处理这种情况。

大家应该对上一章中介绍原子性时,给出的MultiThreadLong案例还记忆犹新吧!我想,没有人愿意就这么把数据“写坏”。那这种情况,应该怎么处理才能保证每次写进去的数据不坏呢?最简单的一种方法就是加入关键字volatile声明,告诉编译器,这个long型数据,你要格外小心,因为它会不断地被修改。

从这个案例中,我们可以看到,关键字volatile对于保证操作的原子性是有非常大的帮助的。但是需要注意的是,关键字volatile并不能代替锁,它也无法保证一些复合操作的原子性。比如下面的例子,通过关键字volatile是无法保证i++的原子性操作的。

此外,关键字volatile也能保证数据的可见性和有序性。如下一段代码:主线程中修改了变量希望另一个线程能够看到修改了变量

 

在虚拟机的Client模式下,由于JIT并没有做足够的优化,在主线程修改ready变量的状态后,ReaderThread可以发现这个改动,并退出程序。但是在Server模式下,由于系统优化的结果,ReaderThread线程无法“看到”主线程中的修改,导致ReaderThread永远无法退出(因为代码第7行判断永远不会成立),这显然不是我们想看到的结果。这个问题就是一个典型的可见性问题

注意:可以使用Java虚拟机参数-server切换到Server模式


2.4 分门别类的管理:线程组(ThreadGroup,注意:是线程组不是线程池,可以创建线程加入线程组中)


2.5 驻守后台:守护线程(Daemon)(守护线程守护的是用户线程,xxThread.setDaemom(true))

守护线程是一种特殊的线程,就和它的名字一样,它是系统的守护者,在后台默默地完成一些系统性的服务,比如垃圾回收线程、JIT线程就可以理解为守护线程。与之相对应的是用户线程,用户线程可以认为是系统的工作线程,它会完成这个程序应该要完成的业务操作。如果用户线程全部结束,则意味着这个程序实际上无事可做了。守护线程要守护的对象已经不存在了,那么整个应用程序就应该结束。因此,当一个Java应用内只有守护线程时,Java虚拟机就会自然退出

上述第16行代码将线程t设置为守护线程。这里注意,设置守护线程必须在线程start()之前设置,否则你会得到一个类似以下的异常,告诉你守护线程设置失败。但是你的程序和线程依然可以正常执行,只是被当作用户线程而已。因此,如果不小心忽略了下面的异常信息,你就很可能察觉不到这个错误,你就会诧异为什么程序永远停不下来了呢?

在这个例子中,由于t被设置为守护线程,系统中只有主线程main为用户线程,因此在main线程休眠2秒后退出时,整个程序也随之结束。但如果不把线程t设置为守护线程,那么main线程结束后,t线程还会不停地打印,永远不会结束。


2.6 先做重要的事:线程优先级(xxxThread.setPriority())

Java中的线程可以有自己的优先级。优先级高的线程在竞争资源时会更有优势,更可能抢占资源,当然,这只是一个概率问题。如果运气不好,那么高优先级线程可能也会抢占失败。由于线程的优先级调度和底层操作系统有密切的关系,在各个平台上表现不一,并且这种优先级产生的后果也可能不容易预测,无法精准控制,比如一个低优先级的线程可能一直抢占不到资源,从而始终无法运行,而产生饥饿(虽然优先级低,但是也不能饿死它呀)。因此,在要求严格的场合,还是需要自己在应用层解决线程调度问题。

在Java中,使用1到10表示线程优先级。一般可以使用内置的三个静态标量表示:


2.7 线程安全的概念与关键字synchronized

线程安全就是并行程序的根基。大家还记得那个多线程读写long型数据的案例吧!它就是一个典型的反例。但在使用volatile关键字后,这种错误的情况有所改善。但是,volatile关键字并不能真正保证线程安全。它只能确保一个线程修改了数据后,其他线程能够看到这个改动。但当两个线程同时修改某一个数据时,依然会产生冲突。

两个线程同时对i进行累加操作,各执行10 000 000次。我们希望的执行结果当然是最终i的值可以达到20 000 000,但事实并非总是如此。如果你多执行几次下述代码就会发现,在很多时候,i的最终值会小于20 000 000。这是因为两个线程同时对i进行写入时,其中一个线程的结果会覆盖另外一个的(虽然这个时候i被声明为volatile变量)。

关键字synchronized的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全性(也就是说在上述代码的第5行,每次应该只有一个线程可以执行)。如下图有两种写法

关键字synchronized可以有多种用法,这里做一个简单的整理。● 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。● 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。● 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。

在本例中就是instance对象。我不厌其烦地给出main函数的实现,是希望强调第14、15行代码,也就是Thread的创建方式。这里使用Runnable接口创建两个线程,并且这两个线程都指向同一个Runnable接口实例(instance对象),这样才能保证两个线程在工作时,能够关注到同一个对象锁上去,从而保证线程安全。

而下图是错误的

上述代码犯了一个严重的错误。虽然在第3行的increase()方法中,声明这是一个同步方法,但很不幸的是,执行这段代码的两个线程指向了不同的Runnable实例。由第13、14行代码可以看到,这两个线程的Runnable实例并不是同一个对象。因此,线程t1会在进入同步方法前加锁自己的Runnable实例,而线程t2也关注于自己的对象锁。换言之,这两个线程使用的是两把不同的锁。因此,线程安全是无法保证的。

但我们只要简单地修改上述代码,就能使其正确执行。那

就是使用关键字synchronized的第三种用法,将其作用于静态方法。将increase()方法修改如下

这样,即使两个线程指向不同的Runnable对象,但由于方法块需要请求的是当前类的锁,而非当前实例,因此,线程间还是可以正确同步

除了用于线程同步、确保线程安全外,关键字synchronized还可以保证线程间的可见性和有序性。从可见性的角度上讲,关键字synchronized可以完全替代关键字volatile的功能,只是使用上没有那么方便。就有序性而言,由于关键字synchronized限制每次只有一个线程可以访问同步块,因此,无论同步块内的代码如何被乱序执行,只要保证串行语义一致,那么执行结果总是一样的。而其他访问线程,又必须在获得锁后方能进入代码块读取数据,因此,它们看到的最终结果并不取决于代码的执行过程,有序性问题自然得到了解决(换言之,被关键字synchronized限制的多个线程是串行执行的)。


2.8 程序中的幽灵:隐蔽的错误

2.8.1 无提示的错误案例

2.8.2 并发下的ArrayList

我们都知道,ArrayList是一个线程不安全的容器。如果在多线程中使用ArrayList,可能会导致程序出错。究竟可能引起哪些问题呢?试看下面的代码:

第一,程序正常结束,ArrayList的最终大小确实200万。这说明即使并行程序有问题,也未必会每次都表现出来。第二,程序抛出异常。[插图]这是因为ArrayList在扩容过程中,内部一致性被破坏,但由于没有锁的保护,另外一个线程访问到了不一致的内部状态,导致出现越界问题。第三,出现了一个非常隐蔽的错误,比如打印如下值作为ArrayList的大小。

注意:改进的方法很简单,使用线程安全的Vector代替ArrayList即可。

2.8.3 并发下诡异的HashMap

HashMap同样不是线程安全的。当你使用多线程访问HashMap时,也可能会遇到意想不到的错误。不过和ArrayList不同,HashMap的问题似乎更加诡异

最简单的解决方案就是使用ConcurrentHashMap代替HashMap。

2.8.4 初学者常见的问题:错误的加锁

如下错误代码

结果却得到了一个比20 000 000小很多的数字,比如15 992 526。这说明什么问题呢?一定是这段程序并没有真正做到线程安全!但把锁加在变量i上又有什么问题呢?似乎加锁的逻辑也是无懈可击的。要解释这个问题,得从Integer说起。在Java中,Integer属于不变对象,即对象一旦被创建,就不可能被修改。也就是说,如果你有一个Integer对象代表1,那么它就永远表示1,你不可能修改Integer对象的值,使它为2。那如果你需要2怎么办呢?也很简单,新建一个Integer对象,并让它表示2即可。

两个线程每次加锁可能都加在了不同的对象实例上,从而导致对临界区代码控制出现问题。

解决很简单,对变量枷锁改成对对象枷锁

发布了24 篇原创文章 · 获赞 1 · 访问量 3416

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104286654