并发编程
什么是进程
操作系统进行资源分配的基本单位
什么是线程
一个程序不同的执行路径,调度的执行的基本单位,多个线程共享同一个进程里的资源
- 单线程:程序中没有同时执行的运行路径(主线程-main)
- 多线程:程序中同时不同的分支在同时运行
线程切换
- CPU:ALU(计算单元)+ Registers(寄存器)+ PC(程序计数器)
- ALU:对寄存器中的数据运行指定进行计算
- Registers:存放线程的数据
- PC:线程执行到的指令
某线程执行时将其指令与数据放入CPU进行运算再放入缓存区,当OS切换到其他线程,该线程重复先前步骤如此来回切换,由哪个线程占用CPU执行由OS决定(缓存区的线程也会被OS切换执行)
从底层的角度出发,线程的切换也是需要消耗资源的(由OS进行线程的切换)
什么是纤程/协程
1
什么是程序
可执行文件,如exe文件
单核CPU设定多线程是否有意义
有意义,有的线程是CPU密集型,大量时间在做计算;有的线程是IO密集型,大量的时间在等,等时间到了,做一些简单的拷贝等操作;在等待的时候即可切换到其他线程
工作线程数是不是设置的越大越好
不是,线程的上下文切换是由用户态装换为内核态,会消耗CPU资源
工作线程数(线程池中线程数量)设置多少合适
计算公式
如:等待与计算时间同为50%,当有1核CPU,期望利用率为100%的时候,设置2个线程最佳(1*1*2);同理有两核CPU时4个线程最佳
- 在没有其他线程的条件下可根据CPU的核数进行设置,但在同一台机器上不一定只有正在跑的线程;
- 从安全角度,不能让CPU百分之百,充分利用CPU还得给CPU留点余量 20%
一般来说需要使用工具(Profiler)进行测算(针对单机)
- 本地开发:JProfiler
- 远程服务:Arthas
创建线程的五种方法
继承Thread,重写run方法,最后继承的对象调用start()开启线程(Java类单继承)
实现Runable接口,重写run方法,构建Thread对象传入该对象调用start()开启线程(更灵活,实现Runable还可从其他类继承)
lambda表达式
new Thread(() -> {XXX}).start();
线程池
ExecutorService service = Executors.new CachedThreadPool(); service.execute(() -> { System.out.println("Hello ThreadPool"); }); service.shutdown();
实现Callable
<T>
,重写call方法,通过指定范型定义call方法的返回值
使用线程池执行
//这里假设MyCall实现了Callable<String>,使用如上线程池 Future<String> f = service.submit(new MyCall()); String s = f.get(); //获取call()的返回值,该方法为线程阻塞的(待s获取到返回值后才能往下继续执行) service.shotdown();
创建线程执行
//假设MyCall实现了Callable<String> FutureTask<String> task = new FutureTask<>(new MyCall()); new Thread(task).start(); task.get();
如上方式本质上都是new Thread().start()
JAVA的6中线程状态
- NEW : 线程刚刚创建,还没有启动
- RUNNABLE : 可运行状态,由线程调度器可以安排执行
- 包括READY和RUNNING两种细分状态
- WAITING: 等待被唤醒
- TIMED WAITING: 隔一段时间后自动唤醒
- BLOCKED: 被阻塞,正在等待锁
- TERMINATED: 线程结束
如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wEOY5Fgp-1662305409550)(/Users/armin/Desktop/file/多线程与高并发/多线程与高并发/./img/线程状态图.png)]
只有synchronized(会经过OS的调度)时,线程状态才会进入BLOCKED,其余时候线程阻塞一般都是WAITING
线程打断interrupt
- interrupt():打断某个线程(设置标志位)
- isInterrupted():查询某线程是否被打断过(查询标志位)
- static interrupted():查询当前线程是否被打断过(查询标志位),并重置打断标志
sleep() wait() join()
- 当前线程设置标志位位true时会抛InterruptedException异常并且重置标志位为false
- 锁竞争(synchronized,ReentrantLock)的过程中不会被interrupte()干扰,也不会抛异常
- 如果想要在锁竞争时打断使用ReentrantLock对象的lockInterruptibly()方法
线程的结束
- 自然结束(能自然结束就尽量自然结束)
- Thread对象的stop():不建议,容易产生数据不一致的问题(直接停止线程,不处理善后工作)
- Thread对象的suspend()(暂停)与resume()(继续):不建议,容易产生死锁问题(如果当前线程持有一把锁,并且暂停了,他是不会释放锁的,什么时候继续未知,所以导致可能产生死锁)
- private static volatile flag:相对优雅的结束方式,缺陷在于不能精确的控制结束的时机,不能依赖中间状态
- 不适合某些场景(如:还没有同步的时候,线程做了阻塞操作,没有办法循环回去)
- 打断的时间也不是特别精确(如:一个阻塞容器,容量为5的时候结束生产者,但是由于volatile同步线程标志位的时间控制不是很精确,有可能生产者还继续生产一段时间)
- Interrupt() and isInterrupted()进行判断,比较优雅,但也不能控制结束的时机(需要用到锁才能进行精准控制)
并发编程三大特性
- 可见性(visibility)
- 有序性(ordering)
- 原子性(atomicity)
可见性
多个线程中,一个线程修改的同一变量,其他线程,都能读到修改后的值(线程本地备份和主存数据的同步)
如何保证可见性:
每个线程运行时,都会把变量拷贝到线程本地,直接修改running是不会修改到t1线程中拷贝的running值
- 使用volatile修饰:当使用volatile修饰running后,每次读的时候都是从主存中获取
- volatile修饰引用类型,只能保证引用本身的可见性,不能保证内部字段的可见性
- 某些语句的执行会触发:synchronized
缓存行(一个Cache Line = 64Byte)
根据缓存行的概念,多线程修改同一缓存行的数据,效率会变低(同一缓存行中不同对象由于对象无法达到64字节,所以每个线程得到的都是不同的相邻对象,当这相邻的对象被不同的线程赋值,由于不同线程拿到的数据都是一个缓存行,所以相邻的对象也都拿到了,但不同线程修改这同一缓存行由于底层的协议机制【缓存一致性协议:MESI Cache其中一种(多个CPU之间,其中一个数据修改后,会通知另外一个CPU数据已修改)】会相互影响)
缓存行为什么是64字节:
- 缓存行越大,局部性空间效率越高,单独去时间慢
- 缓存行越小,局部空间效率越低,但读取时间快
- 去一个折中值,目前多用:64byte
@Comtended
- 定义在全局变量,该变量自动补齐一个缓存行
- 需要加jvm参数:-XX:-RestrictContended(去除限制)
- 仅限于JDK1.8
有序性
概念:单线程保证最终一致性
程序真实按顺序执行的吗未必(乱序执行在多线程的情况下可能产生难以察觉的错误)
为何乱序
简单说,为了提高效率;乱序前提,前后两条语句没有依赖关系(不影响单线程的最终一致性)
举个例子:我要煮10个饺子,但饺子还没包,难道我需要等水开了再去包饺子再下饺子?未必,我可以再烧水的时候把饺子包好待水开后直接下饺子
通过一个小程序认识可见性和有序性
- ready没有volatile修饰;可能会存在主线程的赋值t线程中不可见
- number 与 ready 变量没有依赖关系:导致number可能输出0
public class T02_NoVisibility { private static boolean ready = false; private static int number; private static class ReaderThread extends Thread { @Override public void run() { while (!ready) { Thread.yield(); //让当前线程从运行状态转为就绪状态 } System.out.println(number); } } public static void main(String[] args) throws InterruptedException { Thread t = new ReaderThread(); t.start(); number = 42; ready = true; //由于缓存一致性协议MESI的主动性也可能会更新线程中的该变量 t.join(); //join方法将挂起调用线程的执行,直到被调用的对象完成它的执行 } }
对象的半初始化状态
- 0:T对象分配堆内存空间,成员变量初始化
m=0
(对象的半初始化状态)- 4:调用构造方法
m=8
(初始化完成)- 7:对象建立关联
t = new T();
如下程序有可能输出中间状态num=0
原因:指令7与4互换了顺序
import java.io.IOException; public class T03_ThisEscape { private int num = 8; public T03_ThisEscape() { new Thread(() -> System.out.println(this.num)).start(); } public static void main(String[] args) throws IOException { new T03_ThisEscape(); System.in.read(); //目的阻塞当前主线程,保证T03_ThisEscape执行完 } }
解决方法:不要再构造方法中直接启动线程
import java.io.IOException; public class T03_ThisEscape { private int num = 8; Thread t; public T03_ThisEscape() { t = new Thread(() -> System.out.println(this.num)); } public void start() { t.start(); } public static void main(String[] args) throws IOException { new T03_ThisEscape(); System.in.read(); //目的阻塞当前主线程,保证T03_ThisEscape执行完 } }
JVM内存屏障(保证代码执行的有序性)
- LoadLoad:读与读顺序不可换
- StoreStore:写与写顺序不可换
- LoadStore:读与写顺序不可换
- StoreLoad:写与读顺序不可换
volatile实现细节(保证线程的可见性,禁止指令的重排序)
JVM层面
当写一个 volatile 变量时,JVM 会把该线程对应的本地内存中的共享变量刷新到主内存
当读一个 volatile 变量时,JVM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
理解了这个语义就能很清楚的知道,volatile其实是指对线程本地内存的操作,volatile读之后,所有的读操作应该是从主内存中读取,不加LL屏障如果与后面的读操作发生了指令重排,后面的读操作就会读取到线程本地内存的数据
hotspot实现
bytecodeinterpreter.cpp
int field_offset = cache->f2_as_index(); if (cache->is_volatile()) { if (support_IRIW_for_not_multiple_copy_atomic_cpu) { OrderAccess::fence(); } }
orderaccess_linux_x86.inline.hpp
inline void OrderAccess::fence() { if (os::is_MP()) { // always use locked addl since mfence is sometimes expensive #ifdef AMD64 __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory"); #else __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory"); #endif } }
LOCK 用于在多处理器中执行指令时对共享内存的独占使用
它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效
另外还提供了有序的指令无法越过这个内存屏障的作用
原子性
原子性操作:当前线程操作不被其他线程所打断(如:当前线程给全局变量a赋值,等当前线程赋值完成后其他线程才能对该变量进行操作)
涉及的一些概念:
- race condition(竞争条件):指多个线程访问共享数据产生的竞争,导致数据不一致(unconsistency),并发访问下产生的不期望出现的结果
- 如何保证数据一致:线程同步(并发编程序列化)
- monitor(管程):锁
- critical section:临界区(如synchronized代码块中的代码称为临界区;当持有锁的时候,执行的代码必须按序列化执行)
- 如果临界区执行时间长,语句多,叫做:锁的粒度比较粗,反之锁的粒度比较细
上锁的本质
将并发编程序列化(原本的并发执行变为线性执行,多个线程上锁时必须保证为同一把锁)
保障操作的原子性
悲观的认为这个操作会被别的线程打断(悲观锁【重量级】)synchronized(保障:可见性、原子性)
乐观的认为这个操作不会被别的线程打断(乐观锁/自旋锁/无锁【轻量级】)CAS(Compare And Set/Swap/Exchange)操作
CAS
自旋:在E和当前的新值N比较不等时,从新读取N进行计算再比较的过程
ABA问题
- 如果E为非引用类型不需要解决
- 如果E为引用类型:加版本解决,每个线程拿到该引用对其版本进行修改
- 版本参数定义:时间戳/数字/boolean
CAS机制要起作用,CAS本省就需要保证原子性(在比较赋值的时候)
AtomicXXX
类CAS底层实现:本质上还是加锁了(保证了原子性)lock cmpxchg //汇编指令(跟踪本地C++方法发现)
两种锁的效率
不同场景:
- 重量级(synchronized):临界区执行时间比较长,等的人多
- 自旋锁:临界区执行时间比较短,等的人少
- 悲观锁不会过多的消耗CPU,但会线性执行
- 乐观锁某些情况会一直自旋进而消耗CPU资源
实战采用synchronized,其内部即有自旋锁又有偏向锁以及重量级锁,内部进行调优升级已经很不错了
synchronized如何保障可见性
在解锁后会将内存的所有状态与缓存的状态进行刷新对比,然后下一个线程才能继续