多线程学习笔记


学习资料:
https://www.cnblogs.com/paddix/p/5374810.html
https://www.bilibili.com/video/BV1oK4y1N7jd?p=1

1 进程,线程,纤程的联系与区别

在这里插入图片描述

1.1 透明化计算机组成的角度理解

1.1.1 进程

进程是操作系统分配资源的基本单位
进程是一个静态概念, 不同的进程写在内存的不同位置。多进程并行处理就是指将进程写在内存的不同位置,来回切换。

1.1.2 线程

线程是操作系统调度执行的基本单位
线程是一个动态概念,比如main函数,启动进程中的第一个线程——主线程。
线程可以理解为执行路线,多线程即存在多个执行路线。

1.1.3 纤程

用户管理的线程,无需经过操作系统,可以提高效率。

1.1.4 进程与线程

每个进程都被系统在内存中分配单独的空间,每个线程位于进程内部,位于同一进程内部的线程共享同一块内存空间,每个线程是系统切换调度给CPU执行的基本单位。

1.2 从计算机组成的角度理解

在这里插入图片描述
程序运行过程中,CPU用于做计算,内存用于存储数据和指令。程序在运行之前,静静地存储在磁盘上。每双击运行一个磁盘上的程序之后,操作系统就会将该程序从磁盘上写入内存一次,对应一个进程。

执行程序时,需要将数据和指令放入CPU,将数据放入寄存器(Register),将指令(的位置)放入程序计数器(Programm Counter, PC)。

CPU会调用算术逻辑单元(ALU, Arithmetic and Logic Unit),对寄存器中的数据进行计算,计算完成后将计算结果写回内存。

1.3 从线程调度的角度理解

1.3.1 线程调度与线程切换

在这里插入图片描述
操作系统负责协调线程的执行,CPU仅负责使用算术逻辑单元根据指令对寄存器中的数据做计算。

操作系统中协调线程执行的部件称为线程调度器,线程的执行顺序、每个线程执行多长时间都由操作系统的线程调度器根据线程调度算法来决定。线程调度算法:linux使用CFS算法。

当线程执行被操作系统打断,会保存现场,将线程的执行情况存入缓存中;操作系统将新的线程放入CPU执行;当操作系统需要取回被打断的线程继续执行时,会恢复现场,从缓存中取出之前保存的现场,放入CPU继续执行。

上述过程称为线程切换,线程切换也需要占用CPU资源、花费一定时间。

因此,一个CPU核可以并发执行多个线程,由操作系统进行线程切换,一个时刻仅调度执行一个线程。

1.3.2 几个问题

  1. 工作线程数是不是设置的越大越好?
    答:不是,因为线程切换也需要占用CPU资源、花费一定时间。

  2. 工作线程数(线程池中线程数量)设多少合适?
    答:一个线程是一个执行路线,每个线程的执行可以分成两段时间,即占用CPU的时间和不占用CPU的时间(等待)。在不占用CPU的等待时间内,将该线程切换为其他需要占用计算资源的线程执行,可以取得更高效率。
    需要根据线程的等待时间与计算时间的比例、CPU核数来计算最佳工作线程数。
    线程的等待时间与计算时间的比例可以使用日志统计、相应的工具等方式获取。

  3. 调用sleep方法,是不是一直占用CPU?
    答:不是,在sleep期间,系统会保存线程现场,进行线程切换,执行其他线程。

  4. 单核CPU设定多线程是否有意义?
    答:有意义。因为一个线程是一个执行路线,每个线程的执行可以分成两段时间,即占用CPU的时间和不占用CPU的时间(等待)。在不占用CPU的等待时间内,将该线程切换为其他需要占用计算资源的线程执行,可以取得更高效率。

1.4 补充小概念——线程撕裂者

一颗CPU中有一到多个核,每个核在一个时刻可以执行一个线程,有几个核该CPU在一个时刻就能同时执行几个线程(并行)。
每个CPU核心中有用于计算的ALU,每次可以做一个线程的计算。若一个ALU对应多个寄存器组,则不同的寄存器组可以存放不同的线程数据。进行线程切换时,若线程数据已经在寄存器组中,则不需要从CPU外部取线程数据。内存的速度比CPU慢数十到上百倍。如果在CPU内部做线程切换,切换几乎不需要时间,会有一个核心能在一个时刻同时执行多个线程的错觉。
这种现象称为线程撕裂者

2 线程的执行与调度

2.1 线程中的三个基本概念

2.1.1 可见性

2.1.1.1 什么是可见性

可见性是指一个线程对共享变量的修改,对于另一个线程来说是否是可以看到的。

2.1.1.2 数据(共享变量)可见性

在这里插入图片描述
举例说明可见性:线程1在开始执行时,会从内存读入数据,然后一直使用本地的(寄存器内的)数据进行运算,比如使用了变量t=true。如果在线程2中将t设置为t=false,那么线程2会将其对应的本地的t设置为false,然后将内存中的t也改写为false。但是由于线程1一直在使用自己本地的t进行计算,所以在线程1中的t仍然为true。
具体的,例如,下图中的线程不会结束,尽管内存中的running已被更改为false,但本地的running变量一直为true,所以线程中的while循环会一直进行下去。
在这里插入图片描述

2.1.1.3 缓存一致性协议

由于CPU访问内存的速度比CPU访问内部寄存器的速度慢太多,为了加快取数据的速度,CPU的寄存器与内存之间有一般有三级缓存(工业实践决定使用三级)。
CPU在取数据时优先访问寄存器,其次是访问L1缓存,再是L2缓存,再是L3缓存,如果缓存中都没有,才会去访问内存。
上面的例子中,running变量不会与内存中的值同步刷新,所以子线程一直在使用本地running变量,主线程中更改该变量的值,并不会影响到子线程。
在这里插入图片描述
因此,需要有机制确保变量在多个缓存之间的值保持一致。——缓存一致性协议(硬件级别的协议),例如Intel公司cpu的缓存一致性协议MESI
在这里插入图片描述
若要实现多个缓存间的一致性,比如要实现某个变量在不同缓存间的同步,需触发硬件级别的缓存一致性协议。
在这里插入图片描述
比如,在上面的例子中,若在子线程的while循环中加入System.out.println()方法,由于该方法会被翻译成汇编语言,其中包含了会出发MESI协议的指令,所以当主线程更改running变量的值到内存后,该变量在内存中的值对子线程来说是可见的。由于触发缓存一致性协议,子线程在运算时会获取更改后的running,while循环得以结束,子线程得以结束。

2.1.1.4 volatile关键字

作用:

  1. 在java中保证不同线程间变量的可见性:某个线程对变量值的改变对于其他线程来说是不能马上可见的,需要靠CPU的缓存一致性协议来保证可见性。
  2. 禁止指令重排序(CPU为提高性能,会并发的执行代码指令)

注意:

  1. 底层如何具体实现,比如是通过某些汇编语句或是通过MESI协议实现,与java无关。
  2. volatile不能保证原子性

举例说明volatile保证多线程访问时的可见性:
下图的例子中,给running加上volatile关键字,在主线程中先开启一个子线程,之后在主线程中更改running的值。这个更改对于之前开启的子线程是可见的,所以子线程的while循环会结束,执行之后的语句,打印出"m end"。
在这里插入图片描述

2.1.1.5 缓存行与并发度

在这里插入图片描述
CPU做计算时,需要读内存数据到本地缓存。读内存数据是按缓存行(Cache Line)来读的,比如上图中若要从内存中读x,则会读入整个缓存行中的所有数据,例如读x时,位于同一缓存行的y也会被读取。
目前,缓存行的大小一般是64字节(工业实践决定缓存行大小)。缓存行越小,读取速度越快,但是命中率越低,缓存行越大,命中率越高,读取速度越慢。
将同步数据写在不同的缓存行,可以提高并发度。写在不同缓存行的方式可以用添加冗余数据的方式实现。比如写一个类,其中有一个volatile static 的long对象,在其前后各填7个long可以保证该类的不同实例的该long对象一定不在同一缓存行。那么在不同的线程去写该类的不同实例的该long对象时,不需要进行相同缓存行的同步,从而提高并发度。
在这里插入图片描述
上述填充缓存行提高并发度的例子在单机最快的消息队列框架disruptor中有具体的体现。——disruptor框架如何使用缓存行填充提高效率。
在这里插入图片描述
在这里插入图片描述

2.1.1.6 总结可见性

可见性问题是由于缓存产生的。

各个线程在不同缓存中修改、读取数据时会产生数据不一致的现象。

数据不一致的问题是通过缓存一致性协议解决的。

通过缓存一致性协议使同一对应缓存行中的数据一致。

若同一对应缓存行中的数据由于修改,在不同缓存间不断同步会影响效率(伪共享)。

要尽量避免伪共享,尽量让多个不同线程会访问的不同数据不要位于同一缓存行。

volatile关键字触发CPU的缓存一致性协议,保证了多线程访问变量时的可见性。

2.1.2 有序性

2.1.2.1 什么是有序性

有序性是指程序在执行的时候,程序的代码执行顺序和语句的顺序是一致的。

2.1.2.2 有序性问题——线程内存在指令乱序

线程内存在乱序执行的情况。
在这里插入图片描述
之所以会出现乱序,是因为在CPU级别,要提高指令的执行顺序,但是访问内存的速度很慢。在从内存取指令的过程中,有可能由于在取前序指令的等待中先执行了后序指令。
在同一线程中前后没有依赖关系的指令可能会调换执行顺序。
在CPU角度,在不影响单线程的最终一致性的前提下可以调换指令的执行顺序。

2.1.2.3 对象的创建过程

对象的创建过程有三步:申请内存,调用构造方法,建立内存与变量的关联/连接。
在这里插入图片描述

2.1.2.4 为什么DCL单例需要volatile

问:DCL(Double Check Lock)到底需不需要volatile?
需要
在这里插入图片描述
假如不使用volatile,有可能影响其他线程对已创建实例的访问。
比如,一个线程创建实例时,在进入第二个if之后,正在创建实例时,若发生汇编语言的指令重排序,第二步、第三步发生指令调换,那么就会先申请内存,然后建立变量与内存连接,最后才调用构造方法进行初始化。
假如在第二步完成但第三步没完成时,有别的线程访问该类的唯一实例,访问前先进行if(INSTANCE==null)判断,由于实例已创建,所以该线程会访问INSTANCE。但是INSTANCE由于指令重排序,创建了但没有初始化,所以访问到的不是正确的INSTANCE的值,而是默认的初始值。
为了避免上述情况,在双重检查锁DCL单例中需要使用volatile关键字。由于volatile能避免指令重排序,所以保证了线程执行时的有序性。
在这里插入图片描述

2.1.2.5 总结有序性

有序性问题是由于CPU的指令重排序产生的。

线程内的语句、内存中的指令可能会由于CPU进行指令重排序导致乱序执行。

有序性被打破,可能在多线程访问时导致逻辑出错。

volatile关键字能禁止指令重排序,保证了线程执行的有序性。

2.1.3 原子性

2.1.3.1 什么是线程的原子性

原子性是指操作是不可分的。其表现在于对于共享变量的某些操作,应该是不可分的,必须连续完成。

2.1.3.2 原子性问题——线程的操作可能被别的线程打断

在下面的例子中,创建了一个long型对象n,在主线程的for循环中开启100个子线程,在每个线程中执行的操作是10000次n++,n最终的值不是100w,而是33275。
这是因为不同的子线程间没有保证原子性。常常在一个线程读取了n的值之后,将修改写到内存之前,这个完整的修改操作被别的线程打断了,别的线程又读入了n的值,再被别的线程打断。而上述被打断的和打断的线程读取到的都是同样的n,它们都会写回相同的值。所以n确实被更改了100w次,但是值不为100w。
在这里插入图片描述

2.1.3.3 synchronized关键字

在并发编程中存在线程安全问题,主要原因有:1.存在共享数据 2.多线程共同操作共享数据
关键字synchronized
作用:
可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时synchronized 可以保证一个线程的变化可见(可见性),即可以代替volatile。
原理:
synchronized通过保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,从而保证只有一个线程可以执行某个方法或某个代码块,保证了线程的原子性;它还通过触发缓存一致性协议保证共享变量的内存可见性。
具体来说,synchronized会改变对象头部的字节码,其中携带的信息是指明该对象的锁归哪个线程所有,从而在别的线程访问该对象时,若发现该对象已被锁定,则会在当前语句上被阻塞(blocked),等待该对象的锁释放,继续执行当前语句。
在这里插入图片描述

2.1.3.4 重量级锁

synchronized关键字的历史是锁的演变历史,从只有重量级锁,发展到有重量级锁和轻量级锁。
在这里插入图片描述
在不同编程语言中,线程与内核级别的线程的对应关系不同。在JAVA语言中,这个数量关系是1:1,在JAVA中,new一个thread对应的是一个操作系统启动的一个内核级别的线程pthread。在Go语言中,开启的是纤程,与内核级别线程的对应关系是m:n(m>n)。

在JAVA的古老版本中,锁的管理都交给操作系统,JAVA中上锁意味着操作系统中对应着一个上了锁的内核级别线程pthread。在JAVA中有几个线程等待该锁释放,在操作系统级别就有几个线程在等待该锁释放。上述的这种在操作系统级别进行实现的锁称为重量级锁。
每个重量级锁都有一个对应的操作系统级别的等待队列,等待队列中的线程一直在队列中等待该锁释放,获取该锁之后才结束被阻塞(blocked)的状态。
在这里插入图片描述

2.1.3.5 自旋锁、乐观锁、CAS

轻量级锁——等待轻量级锁的线程不进入等待队列,在原地循环访问该锁是否已经被释放(自旋),不需要经过操作系统调度,若被释放则获取该锁结束等待。
在这里插入图片描述
对比轻量级锁和重量级锁:
等待重量级锁释放的线程在等待队列中等待锁释放;等待队列由操作系统进行管理;锁释放之后哪个线程能获得锁,结束被阻塞状态,由操作系统进行调度;在等待队列中的线程,在等待锁释放的过程中不消耗CPU资源。重量级锁适合线程多,线程执行时间长的情况。
等待轻量级锁释放的线程需要不断地自旋访问该锁是否已被释放,需要消耗CPU资源。自旋锁/乐观锁适合线程数少,线程执行时间短的情况。

自旋锁举例:
AtomicInteger类使用自旋锁保证线程的原子性,没有使用synchronized,没有经过操作系统的调度。
在这里插入图片描述
AtomicInteger类的incrementAndGet方法底层通过调用原生代码的自旋锁方法即compareAndSwapInt方法实现。
在这里插入图片描述

解释悲观锁与乐观锁:
悲观锁认为在读取变量后,写入更改后的值前,很有可能该变量会被其他线程尝试改写,所以需要在读取该变量的同时上锁,防止在写回更改后的值之前被其他线程改写。
乐观锁则认为在读取变量后,写入更改后的值前,该变量会被其他线程尝试改写的概率较小,只需在写回之前判断该变量的值是否发生变化就决定是否将更改结果写入。如果该变量的值与读入时相等,就认为在读取变量后,写入更改后的值前,该变量没有被改写过,就将更改结果写入
在这里插入图片描述

CAS也被称为乐观锁
底层原理是使用Unsafe这个对象操作java虚拟机JVM,调用原生代码,主要是CompareAndSwap、CompareAndSet这一类的原子操作来实现的,在原子操作中使用CPU级别支持的汇编指令——CAS指令"lock cmpxchg",完成“比较并交换”。
理解ABA问题:ABA问题指在乐观锁访问改写前的对象之后,可能发生对象的值已被更改,然后又被改回了原先的值,那么怎么判断在读取变量后,写入更改后的值前这个对象确实被更改过,是否还要将改写的值写入呢。类比,女朋友分手又复合,如果她还是原来的她就复合,如果已经不是原来的她就不能复合,那么就需要判断她是否已经发生了变化,是不是原来的她。
ABA问题:如果是基础数据类型,那么不影响写入;如果是引用数据类型,那么会影响写入。
解决方法:加上版本号,使用:AtomicStampedReference这个类

2.1.3.6 偏向锁

偏向锁:
记录第一次进来线程的id,当下一次有线程进来的时候,就比较线程id是否是之前的那个线程,如果是,就跳过加锁的过程,不是则进行锁升级,进入自旋锁

2.1.3.7 一些新的锁

ReentrantLock
是一个可重入锁:也就是锁里面还可以加锁。例如:Synchronize
锁必须是可重入的
ReentrantLock和Synchronize的差别:
1.相对于Synchronize,ReentrantLock的API更加丰富,我们可以使用tryLock来尝试获取锁,并进行其他操作,而Synchronize拿不到就要wait了

ReentrantLock:分为公平锁和非公平锁,默认是非公平锁(Synchronize是非公平锁)
公平锁:判断等待队列中是否有线程等待,有就取出来,没有就让当前线程执行
非公平锁:来了直接抢,谁抢到谁就执行

CountDownLatch

2.2 其他

锁升级过程
无锁->偏向锁->乐观锁->悲观锁

VarHandle:
1.可以直接对普通属性进行原子操作
2.比反射操作效率更高,直接操作二进制码

ThreadLocal:

set(T value):把当前的value设置到当前线程的ThreadLocalMap中去,键值为this当前对象(ThreadLocal)

注意:不再使用的时候一定要remove,不然可能造成内存泄漏问题
用途:声明式事务保证同一个connection

JAVA 的引用类型:

一、强引用(普通引用),比如Object o = new Object()---->GC不回回收该对象,内存不够的时候会抛出内存溢出异常
二、软引用:系统内存不足的时候会被GC回收----->可用作缓存
三、弱引用:调用GC会直接回收,内存不足的时候会被回收
四、虚引用:被回收的时候会向引用队列(ReferenceQueue)中插入一个值,用于通知被回收了。(堆外内存)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值