基于Java的锁、多线程、线程池并发编程

1 篇文章 0 订阅
1 篇文章 0 订阅

一. 背景

操作系统
  1. 所有计算机资源是被一个程序独占的,称为裸机
  2. 能够协调程序排队的简单批处理系统
  3. 当程序遇到IO之类的阻塞时,处理器转而处理其他程序,称作多道批处理系统
  4. 给每个程序都分配一点处理器时间去轮流执行,每个程序分配到的执行时间就叫做时间片,这个过程也叫做分时处理。操作系统负责管理时间片具体设置为多大,处理器怎么切换各个程序的执行
  5. 4核、8核即多个处理器
进程
  1. 进程的两个特点:资源所有权、调度/执行,都由操作系统完成分配
  2. 进程的状态:新建、就绪(放入就绪队列)、运行、阻塞(放入阻塞队列)、退出
  3. 进程的缺陷:不同进程的资源无法共享、创建切换销毁成本过大
线程
  1. 线程是针对进程的缺陷的补充,将进程的特点予以拆分,所以可以被调度和执行的单位通常被称作线程或者轻量级进程,而拥有资源所有权的单位通常被称为进程
  2. 线程可以被分成两种类型,一种叫普通线程,另一种就叫守护线程(如java里的垃圾收集器)
JAVA线程基本概念

https://mp.weixin.qq.com/s?__biz=MzIxNTQ3NDMzMw==&mid=2247483732&idx=1&sn=45d9399c8f8de06390ea348b49efd0e9&scene=19#wechat_redirect

线程风险:安全性、活跃性、性能

不同线程调用同一个方法都会为局部变量和方法参数拷贝一个副本,所以这个栈内存是线程私有的,也就是说局部变量和方法参数是不可以共享的;对象或者数组是在堆内存上创建的,堆内存是所有线程都可以访问的,所以包括成员变量、静态变量和数组元素是可共享的,共享变量存在风险

  • 安全性:原子性操作、内存可见性和指令重排序
    • 原子性操作
      • 从共享性,使用局部变量解决问题
      • 从可变性,声明为final类型
      • 加锁解决(JAVA中任何一个对象都可以作为一个锁,也称为内置锁)。
        • 被锁保护的代码块也叫做同步代码块。在同步代码块中的代码要尽量的短,不要把不需要同步的代码也加入到同步代码块,在同步代码块中千万不要执行特别耗时或者可能发生阻塞的一些操作,比如I/O操作
        • 只要一个线程持有了某个锁,那么它就可以进入任何被这个锁保护的代码块,称为锁的重入
        • 对于成员方法来说,我们可以直接用this作为锁。对于静态方法来说,我们可以直接用Class对象作为锁
    • 内存可见性(TODO)
      线程对共享变量的所有操作都必须在自己的工作内存中进行,不能从主内存中读写;而且不同线程之间无法直接访问其它线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成
    • 指令重排序
      指令不按书写顺序执行的情况称为指令重排序,需要遵循代码依赖
      • 同步代码抑制指令重排序,加锁前、锁内、释放锁的三块代码无法重排序,可能导致效率下降
      • volatile变量抑制指令重排序,写读之间的指令无法重排序
      • final变量抑制指令重排序
    • 总结
      • synchronized可以把三个问题都解决掉,但是伴随着这种万能特性,是多线程在竞争同一个锁的时候会造成线程切换,导致线程阻塞,这个对性能的影响是非常大的
      • volatile不能保证一系列操作的原子性,但是可以保证对于一个变量的读取和写入是原子性的,一个线程对某个volatile变量的写入是可以立即对其他线程可见的,另外,它还可以禁止处理器对一些指令执行的重排序
      • final变量依靠它的禁止重排序规则,保证在使用过程中的安全性。一旦被赋值成功,它的值在之后程序执行过程中都不会改变,也不存在所谓的内存可见性问题
  • 活跃性:死锁、饥饿、活锁
    • 死锁
      多个线程试图以不同的顺序来获得相同的锁而造成的死锁也被称为锁顺序死锁。
      如果在持有锁的情况下调用了某个外部方法,那么就需要警惕死锁。
      一旦系统进入死锁状态,将无法恢复,只能重新启动系统。
      • 避免死锁:
        线程在执行任务的过程中,最好进行开放调用,即调用某个方法的时候不持有锁;
        各个线程最好用固定的顺序来获取资源;
        可以让持有资源的时间有限,即锁持有超时即予以释放。
    • 线程饥饿
      如果一个线程因为处理器时间全部被其他线程抢走而得不到处理器运行时间,这种状态被称之为饥饿,一般是由高优先级线程吞噬所有的低优先级线程的处理器时间引起的。
      我们尽量不要修改线程的优先级,具体效果取决于具体的操作系统,并且可能导致某些线程饿死。
    • 活锁
      多个线程虽然都没有停止运行,但是却无法向下执行,这种情况就是所谓的活锁。
      • 避免活锁:在遇到冲突重试时引入一定的随机性。
  • 性能:线程上下文切换,带来一定的性能损耗。

二. 设计线程安全的类

  1. 需要把所有字段都声明为 private 的,把对它们的访问都封装到方法中,对这些方法再进行必要的同步控制
  2. 需要在所有的访问位置都进行必要的同步处理,一种常见的错误是只在写入共享可变字段时才使用同步
  3. 如果使用锁来保护共享可变字段的访问的话,对于同一个字段来说,在多个访问位置需要使用同一个锁。一般情况下,在一个线程安全类中,我们使用同步方法,也就是使用this对象作为锁来保护字段的访问就OK了~。
  4. 在对象创建完成之前就把this引用赋值给别的线程可以访问的变量;内部类对象可以轻松的获取到外部类的引用,两周情况都称为this引用逸出,需要避免。

三. 线程间通信

wait/notify机制
  1. java里提出了一套叫wait/notify的机制。当一个线程获取到锁之后,如果发现条件不满足,那就主动让出锁,然后把这个线程放到一个等待队列里等待去,等到某个线程把这个条件完成后,就通知等待队列里的线程
  2. java里规定了每一个锁都对应了一个等待队列
  3. 静态同步方法的锁对象是该类的Class对象,成员同步方法的锁对象是this对象
  4. 必须在同步代码块中调用wait、 notify或者notifyAll方法
  5. 在同步代码块中,必须调用获取的锁对象的wait、 notify或者notifyAll方法,如果当前线程不持有某个对象的锁,那它就不能调用该对象的wait方法来让出该锁
  6. 在等待线程判断条件是否满足时,应该使用while,而不是if
  7. 在调用完锁对象的notify或者notifyAll方法后,等待线程并不会立即从wait()方法返回,需要调用notify()或者notifyAll()的线程释放锁之后,等待线程才从wait()返回继续执行。
管道流
  1. PipedInputStream:管道输入字节流
    PipedOutputStream:管道输出字节流
    PipedReader:管道输入字符流
    PipedWriter:管道输出字符流
join方法
  1. 在main线程中调用t.join(),代表main线程需要等待t线程执行完成后才能继续执行。也就是说,这个join方法可以协调各个线程之间的执行顺序。

四.并发编程性能

线程开销

虽然线程可以提高处理器利用率,可它本身就会带来一部分性能消耗,包括上下文切换、刷新高速缓存、阻塞带来的各种开销

  • 上下文切换
    1. 保存并恢复线程的某些运行信息(上下文信息)
    2. 加载缓存数据
  • 刷新高速缓存(同步机制带来的问题)
    如果在程序运行过程中对某个共享可变变量进行了并发操作,那么这些因为同步而牺牲的性能也就不那么可惜了,我们把这种情况叫做竞争同步。但是如果程序运行过程中并没有进行对某个共享可变变量的并发操作,那这些同步机制的存在就没有了意义,只会引起性能的降低,我们把这种情况叫做非竞争同步。
    1. 强制刷新缓存到主内存中
    2. 抑制编译器和处理器的优化
  • 阻塞
    1. 阻塞的线程会放弃剩余的时间片,造成上下文切换
    2. 挂起线程会被临时的放到硬盘里
    3. 因为锁而导致其他线程无法继续执行
提高性能

大部分的并发问题都是在调整并发程序性能的时候发生的,所以除非程序的实际测试数据说明真的到了必须提高性能的时候,不然不要轻易的尝试性能调优的手段。

减小锁的竞争

优化的点集中在如何减少线程持有锁的时间,如何降低锁的请求频率

  • 缩小锁的范围
    一般情况下,一个锁只需要把共享可变变量的访问操作保护起来即可,但是如果某些操作需要以原子的方式执行,那必须用锁来保护起来,但是被锁保护的代码里最好不要有可能发生阻塞或者执行时间过长的操作,这样会延长持有锁的时间,从而降低并发程序的性能。
  • 减小锁的粒度
    通过将原来粗粒度的锁拆分成更细粒度的锁,可以减少访问锁的频率,从而减少锁的竞争来提升性能。但是使用这种减小锁的粒度来减少锁的竞争需要特别注意的是:由更细粒度保护的共享可变变量之间是相互独立的,不存在任何不变性条件的。但是更细粒度的锁也会带来另外一个困惑:容易引起死锁问题
  • 锁分段
    分段锁虽然提升了并发程序的性能,但是加大了编程复杂度。
避免使用synchronized来提升性能

volatile虽然会抑制重排序以及刷新高速缓存,但是不会使线程切换,也就是不会发生线程的上下文切换,从而可以省掉这部分的性能损耗。

显式锁
内置锁的缺陷

内置锁语法简单,语义明确,但是它获取锁的方式极其死板

ReentrantLock

不像使用内置锁保护的同步代码块,只要退出了代码块就意味着释放了锁,显式锁需要我们手动调用 unlock 方法来释放锁,所以为了防止在执行具体代码中抛出异常而无法释放锁,所以把 unlock 方法 放在finally块中执行。

  • 轮询锁

    不像lock()方法在获取不到锁的时候会一直阻塞,tryLock()如果获取到了锁会立即返回true,在没有获取到锁的时候会立即返回false,我们可以根据返回结果来判断是否真的获取到了锁。可自定义的重试策略极大的提升了编程的灵活性。

  • 可中断锁

    每个线程都有一个中断状态,初始值为false,一个线程可以给另一个线程发送一个中断信号,接收到这个中断信号的线程的中断状态就被置为true。
    使用这个可中断的锁的时候,需要两个try块,第1个try块是用来捕获lockInterruptibly方法可能抛出的中段异常的,第2个try块是用来释放锁的。

  • 定时锁

    在指定时间内获取一个锁,如果在指定时间内获取到了锁,那么该方法就返回true,如果超过了这个指定的时间,那么就返回false。这个定时获取锁的方法也是有一个InterruptedException的异常说明,也需要需要两个try块。

锁的公平性

不论此时有没有线程持有该锁,新来获取这个锁的线程都会被放到等待队列中排队的话,我们把这样的锁称之为公平锁;如果仅仅是在某个线程持有锁的情况下才会进入等待队列排队,没有线程持有锁的情况下新来的线程可能会先于等待队列中的线程获取到锁的话,我们把这种锁称为非公平锁

读写锁

不管是内置锁,还是ReentrantLock,在一个线程持有锁的时候别的线程是不能持有锁的,所以这种锁也叫做互斥锁。
实际中,只需要保证一个变量可以被多个读线程同时访问,或者被一个写线程访问,但是两者不能同时访问。所以可以用读写锁代替互斥锁
只有某些变量的读取频率特别高,并且我们实际测试证明了使用读/写锁可以明显提升系统的性能,我们才考虑使用读/写锁来替代互斥锁

非阻塞操作
CompareAndSwap

冲突检查机制,底层的处理器已经帮我们实现了在更新一个值的时候比较一下实际值和给定值是否一致,如果一致则更新并返回true,不一致则不更新并返回false的操作。
先比较再更新这个操作的原子性是由底层硬件保证的。
基于这些基础,设计了原子更新基本类型类、原子更新数组、原子更新引用类型、原子更新字段类

局限
  1. ABA问题。比如一个变量原来的值是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,所以直接返回false代表操作失败。在一些使用场景下需要的是检测给定的变量的值是否发生变化,如果变量值变化情况是A-B-A的这种,虽然实际变化了,但是无法检测到。解决的方案就是版本号。
  2. 循环时间长开销大。如果在竞争非常激烈的多线程环境下使用CAS操作,会导致有的线程长时间的进行空循环,虽然什么都没做,但是还是在浪费处理器的执行时间。所以如果在竞争非常激烈的情况下,还不如用锁来进行隔离,起码线程阻塞之后是不会浪费处理器的执行时间的,具体使用锁效率更高还是CAS操作效率更高需要经过实际测试后用数据说话。
  3. 只能保证一个共享变量的原子操作。或者还有一个取巧的办法,就是可以把多个变量都放在一个对象里。
AQS:抽象队列同步器

用于自定义同步工具

同步状态
  1. AQS中维护了一个名叫state的字段,是由volatile修饰的,它就是所谓的同步状态
  2. 在一些线程协调的场景中,一个线程在进行某些操作的时候其他的线程都不能执行该操作,称为独占模式,在另一些线程协调的场景中,可以同时允许多个线程同时进行某种操作,我们把这种情景称为共享模式。我们可以通过修改state字段代表的同步状态来实现多线程的独占模式或者共享模式
  3. 对于我们自定义的同步工具来说,需要自定义获取同步状态与释放同步状态的方式
同步队列

当一个线程获取同步状态失败之后,就把这个线程阻塞并包装成Node节点插入到这个同步队列中,当获取同步状态成功的线程释放同步状态的时候,同时通知在队列中下一个未获取到同步状态的节点,让该节点的线程再次去获取同步状态

ReentrantLock的内部实现
  1. 显式锁的本质其实是通过AQS对象获取和释放同步状态,而内置锁的实现是被封装在java虚拟机里的。wait/notify机制只适用于内置锁,在显式锁里需要另外定义一套类似的机制,在我们定义这个机制的时候需要整清楚:在获取锁的线程因为某个条件不满足时,应该进入哪个等待队列,在什么时候释放锁,如果某个线程完成了该等待条件,那么在持有相同锁的情况下怎么从相应的等待队列中将等待的线程从队列中移出。
  2. 原来的一个内置锁对象只能对应一个等待队列,现在一个显式锁可以产生若干个等待队列,我们可以根据线程的不同等待条件来把线程放到不同的等待队列上去

五. 任务的执行与线程池

任务的执行

串行、为每个任务创建一个线程、线程的重复利用

Excutor

针对不同的任务策略,定义了不同的Executor子类,客户端程序员只需要把Runnable任务放到执行器的execute方法里就表示任务提交,将任务的提交与执行解耦。

Excutors类库的线程池

Executors类里提供了创建适用于各种场景线程池的工具方法

  1. newFixedThreadPool(int nThreads)
    创建一个拥有固定线程数量的线程池,具体的线程数量由nThreads参数指定。最开始该线程池中的线程数为0,之后每提交一个任务就会创建一个线程,直到线程数等于指定的nThreads参数,此后线程数量将不再变化。
  2. newCachedThreadPool()
    创建一个可缓存的线程池。会为每个任务都分配一个线程,但是如果一个线程执行完任务后长时间(60秒)没有新的任务可执行,该线程将被回收。
  3. newSingleThreadExecutor()
    创建单线程。任务串行执行。
  4. newScheduledThreadPool(int corePoolSize)
    通过此方法可以创建固定线程数量的线程池,而且以延迟或定时的方式来执行任务。
callable与future
  1. 带返回值的Callable任务不能像Runnable一样直接通过Thread的构造方法传入,在Executor的子接口ExecutorService中规定了Callable任务的提交方式
  2. 如果我们通过线程池的submit方法提交了任务,那我们可以得到一个Future对象,它表示一个任务的实时执行状态,并提供了判断是否已经完成或取消的方法,也提供了取消任务和获取任务的运行结果的方法。线程池的submit方法的返回类型Future,其实是一个接口
自定义线程池

JAVA提供了一个ThreadPoolExecutor类,它实现了ExecutorService接口,代表着一个线程池,我们可以通过不同的构造方法参数来自定义的配置我们需要的执行策略

线程的创建与销毁

控制线程池基本大小、最大大小、超过基本大小的线程在空闲时的存活时间几个参数来自定义线程池

管理任务队列
  1. 线程池内部维护了一个阻塞队列,这个队列是用来存储任务的,线程池的基本运行过程就是:线程调用阻塞队列的take方法,如果当前阻塞队列中没有任务的话,线程将一直阻塞,如果有任务提交到线程池的话,会调用该阻塞队列的put方法,并且唤醒阻塞的线程来执行任务。
  2. 分为无界队列、有界队列(有界队列+饱和策略)、同步移交队列
线程工厂

通过指定threadFactory参数,线程池会调用我们自定义的threadFactory的newThread方法来创建线程

六. 终止线程和取消任务

暴力终止

Thread类里设计了一个stop方法实现终止的功能,在线程中即刻抛出ThreadDeath错误,会释放该线程所持有的所有的锁。从外部终止一个线程是危险的,我们应该尽量避免这样的操作。

协作终止

允许一个线程向另一个线程发出一个消息建议终止,至于是否结束线程看该线程对这个消息的响应。

  1. 使用volatile定义共享变量传递
  2. 使用中断,把中断状态当作是终止标识,在一个线程中调用阻塞方法的时候,如果监测到中断状态变为true会抛出异常,我们可以通过捕获这个异常来感知到有别的线程尝试终止此线程
使用Future来取消任务

Future中的cancel方法指的是取消在提交或执行中状态的任务

线程池生命周期
  1. 线程池中执行的任务有4种状态,分别是创建、提交、执行中和完成
  2. 线程池的状态,分别是运行、关闭、终止
关闭线程池

shutdown方法是平缓的关闭线程池,意思是:不再接受新的任务,同时等待已经提交的任务,也就是在阻塞队列中和正在执行的任务执行完成。。而shutdownNow方法是粗暴的关闭线程池,意思是:不再接受新的任务,尝试取消所有正在运行中的任务,不再执行还在任务队列中的方法,而是把它们放在一个List中返回给调用者。

七. 多线程下使用容器

  1. 同步容器,其实就是在容器类的各个操作都声明为同步方法
  2. 定义了许多新的在多线程环境中使用的容器和工具称作并发容器

参考资料

https://mp.weixin.qq.com/mp/homepage?__biz=MzIxNTQ3NDMzMw==&hid=2&sn=8f06e890dc3abda4a4919995bd3773b4&scene=1&devicetype=iOS12.0&version=1700032a&lang=zh_CN&nettype=WIFI&ascene=59&session_us=gh_433eb41427a3&fontScale=100&wx_header=1

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值