Java学习笔记(4)——并发基础

0.168字数 4881阅读 2657

前言

当我们使用计算机时,可以同时做许多事情,例如一边打游戏一边听音乐。这是因为操作系统支持并发任务,从而使得这些工作得以同时进行。

那么提出一个问题:如果我们要实现一个程序能一边听音乐一边玩游戏怎么实现呢?

提出的问题

我们使用了循环来模拟过程,因为播放音乐和打游戏都是连续的,但是结果却不尽人意,因为函数体总是要执行完之后才能返回。那么到底怎么解决这个问题?下面来说。

并行与并发

并行性和并发性是既相似又有区别的两个概念。

并行性是指两个或多个事件在同一时刻发生。而并发性是指连个或多个事件在同一时间间隔内发生。在多道程序环境下,并发性是指在一段时间内宏观上有多个程序在同时运行,但在单处理机环境下(一个处理器),每一时刻却仅能有一道程序执行,故微观上这些程序只能是分时地交替执行。例如,在1秒钟时间内,0-15ms程序A运行;15-30ms程序B运行;30-45ms程序C运行;45-60ms程序D运行,因此可以说,在1秒钟时间间隔内,宏观上有四道程序在同时运行,但微观上,程序A、B、C、D是分时地交替执行的。

如果在计算机系统中有多个处理机,这些可以并发执行的程序就可以被分配到多个处理机上,实现并发执行,即利用每个处理机爱处理一个可并发执行的程序。这样,多个程序便可以同时执行。以此就能提高系统中的资源利用率,增加系统的吞吐量。

并发和并行

进程和线程

进程是指一个内存中运行的应用程序。一个应用程序可以同时启动多个进程,那么上面的问题就有了解决的思路:我们启动两个进程,一个用来打游戏,一个用来播放音乐。这当然是一种解决方案,但是想象一下,如果一个应用程序需要执行的任务非常多,例如LOL游戏吧,光是需要播放的音乐就有非常多,人物本身的语音,技能的音效,游戏的背景音乐,塔攻击的声音等等等,还不用说游戏本身,就光播放音乐就需要创建许多许多的进程,而进程本身是一种非常消耗资源的东西,这样的设计显然是不合理的。更何况大多数的操作系统都不需要一个进程访问其他进程的内存空间,也就是说,进程之间的通信很不方便,此时我们就得引入“线程”这门技术,来解决这个问题。

线程是指进程中的一个执行任务(控制单元),一个进程可以同时并发运行多个线程。打开我们的任务管理器,在【查看】里面点击【选择列】,有一个线程数的勾选项,找到并勾选,可以看到:

任务管理器

进程和线程的区别:

进程:有独立的内存空间,进程中的数据存放空间(堆空间和栈空间)是独立的,至少有一个线程。

线程:堆空间是共享的,栈空间是独立的,线程消耗的资源也比进程小,相互之间可以影响的,又称为轻型进程或进程元。

因为一个进程中的多个线程是并发运行的,那么从微观角度上考虑也是有先后顺序的,那么哪个线程执行完全取决于CPU调度器(JVM来调度),程序员是控制不了的。我们可以把多线程并发性看作是多个线程在瞬间抢CPU资源,谁抢到资源谁就运行,这也造就了多线程的随机性。下面我们将看到更生动的例子。

Java程序的进程(Java的一个程序运行在系统中)里至少包含主线程和垃圾回收线程(后台线程):

你可以简单的这样认为,但实际上有四个线程(了解就好):
[1] main——main线程,用户程序入口
[2] Reference Handler——清除Reference的线程
[3] Finalizer——调用对象finalize方法的线程
[4] Signal Dispatcher——分发处理发送给JVM信号的线程

多线程的优势:

尽管面临很多挑战,多线程有一些优点使得它一直被使用。这些优点是:

  • 资源利用率更好
  • 程序设计在某些情况下更简单
  • 程序响应更快

(1)资源利用率更好

想象一下,一个应用程序需要从本地文件系统中读取和处理文件的情景。比方说,从磁盘读取一个文件需要5秒,处理一个文件需要2秒。处理两个文件则需要:

1| 5秒读取文件A
2| 2秒处理文件A
3| 5秒读取文件B
4| 2秒处理文件B
5| ---------------------
6| 总共需要14秒

从磁盘中读取文件的时候,大部分的CPU时间用于等待磁盘去读取数据。在这段时间里,CPU非常的空闲。它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用CPU资源。看下面的顺序:

1| 5秒读取文件A
2| 5秒读取文件B + 2秒处理文件A
3| 2秒处理文件B
4| ---------------------
5| 总共需要12秒

CPU等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU会去处理第一个文件。记住,在等待磁盘读取文件的时候,CPU大部分时间是空闲的。

总的说来,CPU能够在等待IO的时候做一些其他的事情。这个不一定就是磁盘IO。它也可以是网络的IO,或者用户输入。通常情况下,网络和磁盘的IO比CPU和内存的IO慢的多。

(2)程序设计更简单

在单线程应用程序中,如果你想编写程序手动处理上面所提到的读取和处理的顺序,你必须记录每个文件读取和处理的状态。相反,你可以启动两个线程,每个线程处理一个文件的读取和操作。线程会在等待磁盘读取文件的过程中被阻塞。在等待的时候,其他的线程能够使用CPU去处理已经读取完的文件。其结果就是,磁盘总是在繁忙地读取不同的文件到内存中。这会带来磁盘和CPU利用率的提升。而且每个线程只需要记录一个文件,因此这种方式也很容易编程实现。

(3)程序响应更快

有时我们会编写一些较为复杂的代码(这里的复杂不是说复杂的算法,而是复杂的业务逻辑),例如,一笔订单的创建,它包括插入订单数据、生成订单赶快找、发送邮件通知卖家和记录货品销售数量等。用户从单击“订购”按钮开始,就要等待这些操作全部完成才能看到订购成功的结果。但是这么多业务操作,如何能够让其更快地完成呢?

在上面的场景中,可以使用多线程技术,即将数据一致性不强的操作派发给其他线程处理(也可以使用消息队列),如生成订单快照、发送邮件等。这样做的好处是响应用户请求的线程能够尽可能快地处理完成,缩短了响应时间,提升了用户体验。

多线程的还有一些优势也显而易见:

  • ① 进程之前不能共享内存,而线程之间共享内存(堆内存)则很简单。
  • ② 系统创建进程时需要为该进程重新分配系统资源,创建线程则代价小很多,因此实现多任务并发时,多线程效率更高.
  • ③ Java语言本身内置多线程功能的支持,而不是单纯第作为底层系统的调度方式,从而简化了多线程编程.

上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务的时候,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书独到了多少页的多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。

创建线程的两种方式

继承Thead类:

继承Thread类

运行结果发现打游戏和播放音乐交替出现,说明已经成功了。

实现Runnable接口:

实现Runnable接口

也能完成效果。

以上就是传统的两种创建线程的方式,事实上还有第三种,我们后边再讲。

多线程一定快吗?

先来一段代码,通过并行和串行来分别执行累加操作,分析:下面的代码并发执行一定比串行执行快吗?

多线程一定快吗?

以下是我测试的结果,可以看出,当不超过1百万的时候,并行是明显比串行要慢的,为什么并发执行的速度会比串行慢呢?这是因为线程有创建和上下文切换的开销。

测试结果

继承Thread类还是实现Runnable接口?

吃苹果比赛

想象一个这样的例子:给出一共50个苹果,让三个同学一起来吃,并且给苹果编上号码,让他们吃的时候顺便要说出苹果的编号:

吃苹果比赛

运行结果可以看到,使用继承方式实现,每一个线程都吃了50个苹果。这样的结果显而易见:是因为显式地创建了三个不同的Person对象,而每个对象在堆空间中有独立的区域来保存定义好的50个苹果。

而使用实现方式则满足要求,这是因为三个线程共享了同一个Apple对象,而对象中的num数量是一定的。

所以可以简单总结出继承方式和实现方式的区别:


两种方式的区别

对于这两种方式哪种好并没有一个确定的答案,它们都能满足要求。就我个人意见,我更倾向于实现Runnable接口这种方法。因为线程池可以有效的管理实现了Runnable接口的线程,如果线程池满了,新的线程就会排队等候执行,直到线程池空闲出来为止。而如果线程是通过实现Thread子类实现的,这将会复杂一些。

有时我们要同时融合实现Runnable接口和Thread子类两种方式。例如,实现了Thread子类的实例可以执行多个实现了Runnable接口的线程。一个典型的应用就是线程池。

常见的错误:调用run()方法而非start()方法

创建并运行一个线程所犯的常见错误是调用线程的run()方法而非start()方法,如下所示:

1| Thread newThread = new Thread(MyRunnable());
2| newThread.run();  //should be start();

起初你并不会感觉到有什么不妥,因为run()方法的确如你所愿的被调用了。但是,事实上,run()方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。也就是被执行上面两行代码的线程所执行的。想要让创建的新线程执行run()方法,必须调用新线程的start方法。

吃苹果比赛的问题:线程不安全问题

尽管,Java并不保证线程的顺序执行,具有随机性,但吃苹果比赛的案例运行多次也并没有发现什么太大的问题。这并不是因为程序没有问题,而只是问题出现的不够明显,为了让问题更加明显,我们使用Thread.sleep()方法(经常用来模拟网络延迟)来让线程休息10ms,让其他线程去抢资源。(注意:在程序中并不是使用Thread.sleep(10)之后,程序才出现问题,而是使用之后,问题更明显.)

吃苹果比赛中的问题

为什么会出现这样的错误呢?

先来分析第一种错误:为什么会吃重复的苹果呢?就拿B和C都吃了编号为47的苹果为例吧:

  • ① A线程拿到了编号为48的苹果,打印输出然后让num减1,睡眠10ms,此时num为47。
  • ② 这时B和C同时都拿到了编号为47的苹果,打印输出,在其中一个线程作出了减一操作的时候,A线程从睡眠中醒过来,拿到了编号为46的苹果,然后输出。在这期间并没有任何操作不允许B和C线程不能拿到同一个编号的苹果,之前没有明显的错误仅仅可能只是因为运行速度太快了。

再来分析第二种错误:照理来说只应该存在1-50编号的苹果,可是0和-1是怎么出现的呢?

  • ① 当num=1的时候,A,B,C三个线程同时进入了try语句进行睡眠。
  • ② C线程先醒过来,输出了编号为1的苹果,然后让num减一,当C线程醒过来的时候发现num为0了。
  • ③ A线程醒过来一看,0都没有了,只有-1了。

归根结底是因为没有任何操作来限制线程来获取相同的资源并对他们进行操作,这就造成了线程安全性问题。

如果我们把打印和减一的操作分成两个步骤,会更加明显:


拆成两个步骤

ABC三个线程同时打印了50的苹果,然后同时做出减一操作。

像这样的原子操作,是不允许分步骤进行的,必须保证同步进行,不然可能会引发不可设想的后果。

要解决上述多线程并发访问一个资源的安全性问题,就需要引入线程同步的概念。

线程同步

多个执行线程共享一个资源的情景,是最常见的并发编程情景之一。为了解决访问共享资源错误或数据不一致的问题,人们引入了临界区的概念:用以访问共享资源的代码块,这个代码块在同一时间内只允许一个线程执行。

为了帮助编程人员实现这个临界区,Java(以及大多数编程语言)提供了同步机制,当一个线程试图访问一个临界区时,它将使用一种同步机制来查看是不是已经有其他线程进入临界区。如果没有其他线程进入临界区,他就可以进入临界区。如果已经有线程进入了临界区,它就被同步机制挂起,直到进入的线程离开这个临界区。如果在等待进入临界区的线程不止一个,JVM会选择其中的一个,其余的将继续等待。

synchronized关键字

如果一个对象已用synchronized关键字声明,那么只有一个执行线程被允许访问它。使用synchronized的好处显而易见:保证了多线程并发访问时的同步操作,避免线程的安全性问题。但是坏处是:使用synchronized的方法/代码块的性能比不用要低一些。所以好的做法是:尽量减小synchronized的作用域。

我们还是先来解决吃苹果的问题,考虑一下synchronized关键字应该加在哪里呢?


synchronized应放在哪里?

发现如果还再把synchronized关键字加在if里面的话,0和-1又会出来了。这其实是因为当ABC同是进入到if语句中,等待临界区释放的时,拿到1编号的线程已经又把num减一操作了,而此时最后一个等待临界区的进程拿到的就会是-1了。

同步锁(Lock)

Lock机制提供了比synchronized代码块和synchronized方法更广泛的锁定操作,同步代码块/同步方法具有的功能Lock都有,除此之外更强大,更体现面向对象。

摘自JDK1.6中文版说明的代码

参考资料:


欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693

推荐阅读更多精彩内容