Java并发简介

  

 并发的一些概念

并发:具备处理多个任务的能力,这些任务不一定同时进行

并行:可同时处理多个任务

这两者最关键的差异就是同时处理任务,举一个比较通俗的例子:

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。

你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。

你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

同步:发起调用后,会一直等待,直到有结果返回。

异步:发起调用后,直接返回,当有结果后,被调用方可通过状态,通知或者回调函数来告诉调用方。

通俗的说,同步就是打电话,只要不挂断就能一直进行,异步就像是发短信,发完短信该干嘛干嘛,短信被回复后会有提示音或者震动提醒。

阻塞:当一个调用未返回前,当前线程会被挂起,直到有结果返回

非阻塞:发起调用后,即使调用未返回,也不会将线程挂起

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。

进程:它是操作系统进行资源分配的基本单位,是一个能够独立运行的应用程序

线程:操作系统进行调度的基本单位

进程和线程的差别:

一个程序至少有一个进程,一个进程至少有一个线程;

线程比进程划分更细,所以执行开销更小,并发性更高;

进程是一个实体,拥有独立的资源,而同一个进程中的多个线程共享进程的资源。

竞态条件:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称为存在竞态条件

临界区:导致竞态条件发生的代码区称作临界区

管程(Monitor):管理共享变量以及对共享变量的操作过程,让他们支持并发。

Java中采用的是管程技术,synchronized关键字及wait(),notify(),notifyAll()这三个方法都是管程的组成部分,而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程

并发特点

随着科技的不断发展,各类计算机硬件设施的功能也越来越强大,但是始终存在一个矛盾:cpu,内存,IO设备之间的速度有差异。cpu速度比内存快,内存速度远比IO设备快。

根据木板理论,一个木桶能装多少水是由最短的那块木板决定的,所以盲目的去提升cpu和内存的速度是无效的,针对这个问题,合理平衡利用三者的优势,操作系统和编译程序作出了一些改动:

cpu增加缓存,用于均衡与内存之间的速度差异。

操作系统增加了进程,线程,以用于分时服用cpu,进而平衡cpu和IO之间的速度差异。

编译程序优化指令执行次序,使得缓存能够得到更合理地利用。

这些改动使得程序和计算机拥有了并发的能力,而并发的优点在于可以提升资源利用率和让程序响应更快

提升资源利用率

举个例子,计算机读取一个文件需要5秒,处理这个文件需要2秒,现在有A,B两份文件需要读取和处理,需要时间为:

读取A文件 --- 5秒

处理A文件 --- 2秒

读取B文件 --- 5秒

处理B文件 --- 2秒

一共需要14秒。

在磁盘读取文件时,cpu是空闲的,完全可以给CPU其他的任务,所以我们改变一下执行顺序:

读取A文件 --- 5秒

读取B文件 --- 5秒 ,处理A文件 --- 2秒

处理B文件 --- 2秒

一共需要12秒,不仅缩短了程序执行时间,还提升了工作效率。总的来说,CPU能够在等待IO时做一些别的事情,这不一定是磁盘IO,也可以是网络IO,或者用户输入,通常情况下,网络和磁盘的IO比CPU和内存的IO要慢得多。

程序响应更快

当一个服务器应用,线程在某一个端口监听,当请求进来时,它去处理这个请求,结束后再回去监听。如果遇到了一个需要处理很久的请求,那么这个应用在这段时间内完全没法接收别的请求,只能等待当前请求处理完成,这就是单线程的弊端。如果换成多线程呢?创建一些监听线程,再创建一些工作线程,当请求进来时,监听线程将请求抛给工作线程,然后再回去监听,这样保证了系统不会出现某段时间不可用,并且响应也更快。

桌面应用也是同样如此。如果你点击一个按钮开始运行一个耗时的任务,这个线程既要执行任务又要更新窗口和按钮,那么在任务执行的过程中,这个应用程序看起来好像没有反应一样。相反,任务可以传递给工作者线程。当工作者线程在繁忙地处理任务的时候,窗口线程可以自由地响应其他用户的请求。当工作者线程完成任务的时候,它发送信号给窗口线程。窗口线程便可以更新应用程序窗口,并显示任务的结果。对用户而言,这种具有工作者线程设计的程序显得响应速度更快

并发问题

任何事物都有利弊,并发也不例外。

安全性问题:保证程序的正常性,使得并发处理结果符合预期。这需要保证几个基本特性:

可见性:当某个线程修改了共享变量,其他线程能够立即知晓

原子性:相关操作不会中途被其他线程干扰

有序性:保证线程内串行语义,避免指令重排序

缓存导致的可见性问题

每个线程中都有自己的缓存,当需要修改某个值时,先从共享内存中将这个值读到自己的缓存中,然后进行修改,同步到自己的缓存中,最后再同步到共享内存中。

在多线程的环境中,线程A读到了x的值1,并将它+1,同步到自己的缓存中后,线程B也从共享内存中读到了x的值,这时它还是1,线程B同样也是执行了+1的操作,然后线程A将x的值同步到共享内存,随后线程B也同步到共享内存中。按照正常的逻辑,x的值应该是3,但是线程B并没有读到最新值,导致最终x的结果是2。

线程切换带来的原子性问题

cpu给每个线程都分配一段执行时间,称为时间片。当一个线程的时间片用完后,cpu会切换到另一个线程,称为上下文切换。

还是用上面的x++的例子,x++有三步:获取x的值,将x的值+1,将新的值赋给x

这三步分为三个cpu指令,那么,问题来了,如果执行倒第二步时,时间片用完了切换到下一个线程,而这个线程也需要改x的值,最终得到x的值是我们想要的值吗?

编译优化带来的有序性问题

编译器为了优化性能,有时候会去修改程序中语句的执行顺序,但是建立在不会影响程序的最终结果上。有个很经典的场景:

public class Singleton {
    static Singleton instance;
    static Singleton getInstance(){
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

保证并发安全的思路

互斥同步(悲观锁):无论共享数据是否真的会出现竞争,都要进行加锁,具体案例有synchronized和Lock

非阻塞同步(乐观锁):先进行操作,如果没有其他线程争用,那么操作就成功,否则采用补偿措施(不断地重试,直到成功为止),具体案例有CAS

无同步:如果一个方法本来就不涉及共享数据,那么自然无需同步,具体案例有ThreadLocal(共享变量在每个线程中都创建了一个本地副本,只属于当前线程)

活跃性问题

死锁:线程进入无限期等待的状态。死锁是指两个或多个线程之间互相持有对方需要的锁,因而陷入等待对方释放的状态。

如何避免死锁?

按顺序加锁:在多个线程竞争的情况下,如果加锁没有顺序就很容易导致死锁,那么,固定加锁的顺序呢?所有线程都是以固定的顺序获取锁,就不会遇到死锁的情况了,但是需要提前预知所有锁的情况,程序运行时,很多情况都是无法预知的。

超时释放锁:当线程试图获取锁时,加一个超时时间,超过时间就释放当前获取的所有锁,等待一段时间后再进行重试。

死锁检测:这是一种更好的死锁预防机制,主要针对那些不可能实现按顺序加锁并且超时释放锁也不可行的场景。当一个线程获取到锁后,将其记录在相关的数据结构中(map),除此之外,每当有线程请求锁,也一并记录下来。当线程请求锁失败后,可以遍历锁的关系图来查看是否有死锁发生。

检测出死锁后,有两种处理方式:

释放所有锁并回退

给线程随机设置优先级

活锁:与死锁的竞争相反,活锁的发生是因为相互谦让。两个线程都需要一个共享资源,但是看到资源上已经有了对方在活跃,于是将资源交给对方,等待他们完成操作。

避免活锁:给每个线程随机设置等待时间,因为时间是随机的,所以线程间相互碰撞的概率就比较低了。

饥饿:高优先级的线程总是先获取到资源,低优先级的线程总是在等待。

解决饥饿:cpu不可能实现百分百的公平,我们依然可以通过同步结构在线程间实现公平性的提高,主要有三种办法:

保证资源充足

公平地分配资源

避免持有锁的线程长时间执行

第一和第三个方案实现场景有限,首先是资源的稀缺性是无法解决的,其次也很难缩短线程的执行时间,所以第二个方案更加的适用。实现第二个方案的案例就是使用公平锁,这是一种先来后到的方案,线程的等待是有序的。

性能问题

并发执行不一定比串行快,线程也不是越多执行越快。创建线程和线程上下文切换都有开销

上下午切换:当CPU从执行一个线程切换到另一个线程时,CPU需要保存当前线程的本地数据,程序指针状态,并加载下一个要执行的线程本地数据,指针等等。

如何减少上下文切换?

无锁并发编程:例如使用多线程处理一批数据时,可以将数据编号分批给不同的线程执行,这样就不会发生冲突,不同线程处理不同批的数据。

CAS算法

使用最少线程,防止线程过多造成大部分线程都在等待状态。

资源限制:在并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。

对于硬件资源限制,可以考虑使用集群并行执行程序。

对于软件资源限制,可以考虑使用资源池将资源复用。

总结

并发编程可以总结为三个核心问题:分工、同步、互斥。

分工:是指如何高效地拆解任务并分配给线程。

同步:是指线程之间如何协作。

互斥:是指保证同一时刻只允许一个线程访问共享资源。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值