并发编程总结

这里写目录标题

一、基本概念

1、线程

什么是线程

操作系统在运行一个程序的时候,就会为它创建一个进程。比如说我们启动一个java程序,操作系统就会创建一个java进程,在这个进程里面,我们可以创建很多线程,每个线程都拥有自己的程序计数器和栈,并且还能够访问共享的变量。线程是操作系统调度的最小单元,CPU在这些线程上高速切换,让使用者感觉这些线程是在同时执行

为什么使用多线程

并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升

创建多少线程合适

创建多少线程这个问题的本质是:尝试通过增加线程来提升 I/O 的利用率和 CPU 的利用率,努力将硬件的性能发挥到极致

对于单核 CPU,如果只有一个线程,执行 CPU 计算的时候,I/O 设备空闲;执行 I/O 操作的时候,CPU 空闲,所以 CPU 的利用率和 I/O 设备的利用率都是 50%,我们可以通过多线程去平衡 CPU 和 I/O 设备

对于多核 CPU,我们可以增加线程数提高 CPU 利用率,来降低响应时间(一个方法内使用多线程执行可并行的部分或者并行执行多个方法)

但是一味的增加线程数并可取

如果一个程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还可能会使性能变差,原因是增加了线程切换的成本

我们首先要区分具体场景

  • CPU 密集型:纯 CPU 计算,不涉及 I/O 操作的场景

  • I/O 密集型:只要涉及 I/O 操作的场景都属于 I/O 密集型,因为 I/O 设备的速度相对于 CPU 计算来说都非常长

  • 对于 CPU 密集型:多线程的本质就是提升多核 CPU 的利用率,理论上 线程的数量 = CPU 核数 就是最合适的,工程上,线程的数量会设置为 CPU 核数+1,这样的话,当线程因为偶尔的内存页失效或者其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率

  • 对于 I/O 密集型:最佳的线程数是与程序中 CPU 计算和 I/O 操作的耗时比相关的,对于单核CPU,最佳线程数 =1 +(I/O 耗时 / CPU 耗时),对于多核 CPU,最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

线程优先级

在java线程中,会通过一个整形变量 priority 来控制线程优先级,范围是一到十,默认是5,可以通过对应的set方法修改。理论上优先级高的线程分配时间片数量要多于优先级低的线程,但实际上操作系统可能不太会理会java线程对于优先级的设定

守护线程

Java的线程分为两类:

  • 用户线程:创建一个线程默认是用户线程,属性daemon = false
  • 守护线程:设置属性daemon = true时,就是守护线程

用户线程与守护线程的关系:

用户线程就是运行在前台的线程,守护线程就是运行在后台的线程,一般情况下,守护线程是为用户线程提供一些服务,比如在Java中,GC内存回收线程就是守护线程

JVM 与用户线程共存亡:

当所有用户线程都执行完成,只存在守护线程在运行时,JVM就退出,Java程序在main线程执行退出时,会触发执行JVM退出操作,但是JVM退出方法destroy_vm()会等待所有非守护线程都执行完,里面时用变量numberofnondaemonthreads统计非守护线程的数量,这个变量在新增线程和删除线程时会做增减操作

当JVM退出时,所有还存在的守护线程会被抛弃,既不会执行finally部分代码,也不会执行catch异常

main线程可以比子线程先退出:

main 线程退出前,通过 LEAVE() 方法,调用了 destroy_vm() 方法,但是在 destroy_vm() 方法里面等待着非守护线程执行完,子线程如果是非守护线程,则 JVM 会一直等待,不会立即退出。

线程在还没有通过start()方法启动前,可以通过修改daemon属性来将用户线程转变为守护线程,启动之后就不能修改了

其他特性:

  • 守护线程属性继承自父线程
  • 守护线程优先级比用户线程低

线程间通信

  1. volatile关键字:用volatile关键字来修饰一个变量,就相当于告知程序,对于该变量的访问都需要从共享内存中获取,对变量的改必须同步刷新回共享内存,这样就保证了所有线程对这个变量的可见性。这样的话,一个线程对这个变量进行改变的时候,所有线程都可以感知到变化
  2. 等待通知机制:使用 synchronized 配合 wait()、notify()、notifyAll() 这三个方法来进行是实现,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,调用 wait()方法,然后线程会被阻塞,进入等待队列中,同时释放线程持有的互斥锁,让其他线程有机会获得锁,进入临界区,当线程要求满足时,通知等待队列中的线程,被通知的线程想要重新执行,仍然需要获取到互斥锁(进入锁等待队列中 因为之前调用wait方法的时候释放了互斥锁)或者也可以使用Lock/condition来实现等待通知机制
  3. Thread.join()方法:如果一个线程实例A执行了threadB.join(),其含义是:当前线程A会等待threadB线程终止后threadA才会继续执行。
  4. ThreadLocal:ThreadLocal 其实是一个类似于 Map 的结构,它以 ThreadLocal 这个对象为 key,存进去的值为 vlaue,这个结构被附带在线程上,也就是一个线程可以根据一个ThreadLocal 对象查询到帮定在这个线程上的一个值
  5. InheritableThreadLocal:主线程开了一个子线程,但是我们希望在子线程中可以访问主线程中的 ThreadLocal 对象,也就是说有些数据需要进行父子线程间的传递,我们希望子线程可以看到父线程的 ThreadLocal,那么就可以使用 InheritableThreadLocal

线程切换

操作系统采用时分的形式调度运行的线程,操作系统会分出一个个的CPU时间片,线程会分到若干个CPU时间片,当线程的CPU时间片用完了就会发生线程调度,也就是线程切换,等待着下次分配。线程分配到CPU时间片的多少就决定了使用CPU资源的多少。

当发生线程切换的时候,操作系统要保存当前线程状态,并恢复另外一个线程的状态,这个时候就要使用程序计数器,记住下一条JVM指令的执行地址,这也是程序计数器必须线程私有的原因

线程切换的原因:

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

内核态用户态

什么是用户态和内核态
  • CPU指令集:指令集是 CPU 实现软件指挥硬件执行工作的媒介,具体来说每一条汇编语句都对应了一条 CPU 指令,CPU 指令不止一条的,而是由非常非常多的 CPU 指令集合在一起,组成了一个、甚至多个的集合,每个指令的集合叫:CPU 指令集

  • CPU指令权限分级:CPU 指令是可以直接操作硬件的,要是因为指令操作的不规范,造成的错误是会影响整个 计算机系统 的,所以对CPU指令进行了分装操作,对CPU指令设置了权限,不同级别的权限可以使用的CPU指令是有限的

    • ring0:权限最高,可以使用所有的CPU指令

    • ring1

    • ring2

    • ring3:权限最低,仅能使用常规的CPU指令,这个级别的权限不能使用访问硬件资源的指令,比如 IO 读写、网卡访问、申请内存都不行,都没有权限

  • ring 0 被叫做 内核态,完全在 操作系统内核 中运行,由专门的 内核线程 在 CPU 中执行其任务

  • ring 3 被叫做 用户态,在 应用程序 中运行,由 用户线程 在 CPU 中执行其任务

用户态和内核态切换触发条件

Linux 中默认采用 1:1 线程模型,就是有一个 用户线程,就得在内核中启动一个对应的 内核线程,然后把这个 用户线程 绑定到 内核线程 上。

JVM 采用 Linux 默认函数库,也就是 PThread,1:1 线程模型。java new 一个 Thread 时,是创建了 1个用户线程和内核线程的,然后把用户线程绑定到内线线程中,ring3 的代码在 用户线程中执行,ring0 的代码切换到 内核线程 中去执行,然后使用 内核线程 接受 系统内核的调度,内核线程抢到 CPU 时间片后,用户线程就会激活执行代码

用户态和内核态的切换的实质就是用户线程和内核线程的切换

当在一系统中执行一个程序时,大部分时间时运行在用用户态下的,在其需要操作系统帮助的完成一些用户态自己没有特权和能力完成的操作时就会切换到内核态

用户态切换到内核态的三种方式

  1. 系统调用:这是用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作
  2. 异常:当cpu在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态
  3. 外围设备的中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令是用户态下的程序,那么转换的过程自然就会是由用户态到内核态的切换。如硬盘读写操作的完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等

这三种方式是系统在运行时由用户态切换到内核态的最主要方式,其中系统调用可以认为时用户进程主动发起的,异常和外围设备中断则是被动的。从触发方式上看,切换方式都不一样,但是从最终实际完成由用户态到内核态的切换操作来看,步骤又是一样的,都相当于执行了一个中断响应的过程。系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本一致。

死锁

回答思路:死锁的定义-----死锁产生的原因(使用细粒度锁)-----死锁形成的条件-----如何预防死锁

死锁的定义:一组互相竞争资源的线程因互相等待,导致 “永久” 阻塞的现象

在使用锁的过程中我们会选择用不同的锁对受保护资源进行精细化管理,也就是使用细粒度锁,能够提升性能,但是使用细粒度锁是有代价的,这个代价就是可能会导致死锁

死锁形成的条件:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X
  • 不可抢占,其他线程不能抢占线程 T1 占有的资源
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待

我们只用破坏一个条件就可以避免死锁的发生

解决方案:

  1. 互斥:互斥这个条件没有办法避免

  2. 占用且等待:我们一次性申请所有的资源,这样就不存在等待了(使用等待 - 通知机制,线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁)

    具体实现:使用 synchronized 配合 wait()、notify()、notifyAll() 这三个方法来进行是实现,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,调用 wait()方法,然后线程会被阻塞,进入等待队列中,同时释放线程持有的互斥锁,让其他线程有机会获得锁,进入临界区,当线程要求满足时,通知等待队列中的线程,被通知的线程想要重新执行,仍然需要获取到互斥锁(进入锁等待队列中 因为之前调用wait方法的时候释放了互斥锁)

  3. 不可抢占条件:破坏不可抢占条件的核心是能够主动释放它占有的资源。synchronized 做不到这一点,synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,不能主动释放已经抢占的资源(java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的)

  4. 循环等待:破坏循环等待,需要对资源进行排序,然后按序申请资源(可以给资源加一个 id 属性,这个 id 属性可以作为排序的字段,申请资源时,我们可以按照从小到大的顺序来申请)

2、进程

什么是进程

进程间切换

进程间通信

进程调度算法

3、协程

协程可以理解为线程的线程。线程虽然提升了资源的利用率,但是也存在线程资源有限,而且大多数线程资源处于阻塞的状态,线程之间的开销虽然对比进程少了不少,但是上下文切换的切换开销也不小的问题。协程的出现在一定程度上解决了一些问题。协程的核心在于调度那块由他来负责解决,遇到阻塞操作,立刻放弃掉,并且记录当前栈上的数据,阻塞完后立刻再找一个线程恢复栈并把阻塞的结果放到这个线程上去跑,等达到一定条件后,再恢复原来的栈信息继续执行。协程在Java原生库上不支持的,要引入三方的库才支持。Kotlin和Go语言是原生支持协程的。

4、Java 内存模型

因为 CPU 和 内存的速度差异非常的大,为了合理的利用 CPU,平衡这两者的速度差异,CPU 难题wem加了缓存来均衡与内存的速度;同时会在编译的时候,进行指令重排序,使得缓存能够得到更加合理地利用,但是这样会带来两个问题,就是可见性问题和指令重排序导致的问题

Java 内存模型其实是一套规范,这套规范解决了可见性和有序性问题,能够使 JVM 按需禁用 CPU 缓存和禁止指令重排序。这套规范包括对 volatile、synchronized、final 三个关键字的解析,和 7 个Happens-Before 规则

JMM 通过添加内存屏障来禁止指令重排序,编译器的内存屏障会告诉编译器,不要对指令进行重排序,当编译完成后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在

JMM通过 happens-before 关系向程序员提供跨线程的内存可见性保证。如果一个操作happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

5、happens-before

  1. 程序次序规则: 在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则: 一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序。
  3. volatile变量规则: 对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序。
  4. 线程启动规则: Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则: 线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则: 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. Happens-Before的1个特性:传递性。

6、并发编程三大核心问题

  1. 可见性(缓存导致):多核系统每个 CPU 自带高速缓存,彼此间不交换信息(例子:两个线程对同一份实列变量count累加,结果可能不等于累加之和,因为线程将内存值载入各自的缓存中,之后的累加操作基于缓存值进行,并不是累加一次往内存回写一次)
  2. 原子性(线程切换带来):CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,高级语言的一条语句往往需要多条 CPU 指令完成,而操作系统做线程切换,可以发生在任何一条 CPU 指令执行完(例子:AB两个线程同时进行count+=1,由于+=操作是3步指令①从内存加载②+1操作③回写到主内,线程A对其进行了①②操作后,切换到B线程,B线程进行了①②③,这时内存值是1,然后再切到A执行③操作,这时的值也还是1,PS:这貌似也存在可见性的问题)
  3. 有序性(编译优化指令重排序导致):编译器编译程序时会优化 CPU 指令执行次序,使得缓存能够得到更加合理的使用。编译器为了优化性能,有时候会改变程序中语句的先后顺序,编译器调整了语句的顺序,有可能不影响程序的最终结果,但也有可能导致意想不到的结果。(例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”)

二、 基本操作

1、创建线程的方式

  1. 继承Thread类

    //构造方法的参数是给线程指定名字
    Thread t = new Thread("t1") {
         
        
        @Override
        //run方法内实现了要执行的任务
        public void run() {
         
            
            System.out.printLn("hello");
       
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值