一、并发
并发就是让计算机同时做几件事情,比如一个服务端同时为多个客户端提供服务。因为计算机的运算速度和存储/通信子系统的速度相差太大,大量时间用在磁盘IO/网络通信/数据库访问。通常用1秒内服务端响应的请求数目来衡量服务器性能;服务端是java语言最擅长的领域之一,虚拟机通过多线程的协调保证并发效率:线程间的协调好则效率高,协调不好比如频繁阻塞/死锁则效率低。
Amdahl定律通过系统中并行化与串行化的比重来描述多处理器能获得的运算加速能力;摩尔定律则描述处理器晶体管数量与运行效率直接的关系。这两个定律是硬件发展从追求处理器频率到追求多核心并行处理的发展过程。
1. 硬件的并发与一致性
1)高速缓存:计算机的存储设备与处理器的运算速度有几个量级的差距,这也是为什么有接近处理器运算速度的高速缓存,缓存就相当于中间件。缓存的问题在于数据一致性,多处理器系统中,每个处理器有自己的缓存,对接同一个主内存,当缓存数据不一致时,以谁为准呢?缓存一致性协议用于解决该问题。
2)乱序执行/指令重排:CPU允许将多条指令不按程序顺序分开发送给电路单元处理;并非随意打乱顺序,CPU处理指令依赖情况以保证最终结果的一致性。
二、Java内存模型JMM
JMM的设计是为了屏蔽硬件平台/操作系统的内存差异,实现java程序在各平台的一致内存访问;JMM定义了变量的访问规则,包括变量存储到内存/从内存取出变量,变量指实例/静态字段/数组,不包括栈上的局部变量/方法参数,后者线程私有,不存在竞争。
1. 主内存与工作内存
JMM规定所有变量存储在主内存(类比主内存),每个线程有自己的工作内存(类比高速缓存),保存该线程用到的变量的主内存拷贝,线程对变量的操作在工作内存进行。线程间的值传递通过主内存进行。虚拟机往往让工作内存优先存储于寄存器/高速缓存。
JMM定义的内存操作包括:
1)锁定/解锁:加锁把主内存变量标记为线程独占;解锁后才能被其他线程锁定
2)读取/存储:把主内存变量的值读取到工作内存;把工作内存的变量传送到主内存
3)载入/写入:把读取操作从主内存得到的变量放入工作内存的变量副本;把从工作内存得到的变量值放入主内存变量。
4)使用/赋值:把工作内存变量传递给执行引擎;把执行引擎返回值赋给工作内存变量
JMM定义的内存操作规则:1)变量在工作内存变化后必须同步回主内存;2)新变量只能在主内存中诞生;3)一个变量同一时刻只能被一个线程加锁;对变量加锁会清空工作内存中此变量的值;对变量解锁前,必须先同步回主内存。
2. 并发安全特性
1)原子性:一个或多个语句要么全部执行并且执行的过程不被任何因素打断,要么都不执行。基本数据类型的访问读写是具有原子性的;更大范围的原子性通过加锁/解锁实现(java synchronised),加锁使得并发的过程看起来像是串行的,生活中的类比可以想一想火车上的厕所。
2)可见性:当线程修改共享变量的值,其他线程能够立即得知;JMM规定变量在工作内存修改后必须同步回主内存;java的volatile/synchronized/final可以保证可见性:1)java volatile保证立即同步到主内存,并且每次使用前从主内存刷新,各个工作内存中volatile变量的值可以不同;2)synchronized保证对变量解锁前先同步回主内存;3)final修饰的字段在构造器中初始化完成后,就对其他线程可见。
3)有序性:volatile禁止指令重排;Synchronized:变量同一时刻只允许一个线程加锁。
4)先行发生原则:如果操作A发生在操作B之前,那么A产生的影响能够被B观察到,包括共享变量的修改,发送消息等。该原则用于判断是否线程安全,是否会发生指令重排。虚拟机保证以下次序:1. 程序次序规则:一个线程内,按照代码顺序执行;2. 管程锁定规则:同一个锁,解锁先行于加锁;3. volatile规则:对volatile的写操作先行发生于读操作;4. 线程启动/终止规则:Thread对象的start方法>该线程的所有动作>线程的终止检测;5. 线程中断规则:对线程interrupt方法的调用先行发生于中断检测代码如interrupted;6. 对象终结原则:对象初始化 > finalize;7. 先行发生原则具有传递性:如果A>B; B>C,那么A>C
三、Java与线程
线程是比进程更轻量级的调度执行单位,是CPU调度的基本单位。Java语言级别的支持就是Thread类,他的关键方法都声明为native。
1. 线程实现
1)使用内核线程实现:内核指的是操作系统内核,由OS负责线程调度/切换/把线程交给处理器执行;内核线程的高级接口称为轻量级进程;2)使用用户线程实现:用户线程是和内核线程相对的概念;没有内核的支持,实现复杂,应用少;3)使用用户线程加轻量级进程混合实现
2. Java线程调度
线程调度是系统为线程分配处理器使用权的过程。分为:
1)协同式线程调度:线程执行时间由线程本身控制,执行完成后通知系统切换到其他线程,不存在线程同步问题;缺点是线程执行时间不可控制。
2)抢占式线程调度(java):系统负责分配线程执行时间,线程切换不由线程本身决定。如java中yield方法可以让出执行时间,但无法获取执行时间。通过设置线程优先级给某些线程多分配时间,其他线程少分配一些,java提供10个优先级级别。但是优先级不一定靠谱,还要看操作系统。
3. 线程状态转换
Java语言定义了5中线程状态:
1)新建new:创建后尚未启动的线程。Thread t = new Thread(new RunnableTask());
2)运行Runnable:正在执行或者等待CPU分配执行时间;Thread.isAlive();
3)等待分为:1. 无限期等待:不会被CPU分配时间,等待其他线程唤醒notify(),没有timeout参数的wait()/join()/park()方法会导致该状态;2. 限期等待:不会被CPU分配时间,一定时间后系统唤醒,如TimeUnit.MILLISECONDS.sleep(100),设置timeout的上述方法。
当任务依赖于另一任务计算的值,则调用join()将异步执行的线程变成同步,在线程A中调用线程B的join方法,那么线程A会被挂起,直至线程B执行结束(alive)。
4)阻塞:和等待的区别在于阻塞在等待获取排他锁synchronized,而等待是在等待时间
5)结束:task执行完成/run方法正常结束;interrupt(类似异常退出线程执行/类似于break);Thread.cancel();
四、java对象线程安全级别
对象在一项工作期间,不停的中断和切换,对象的属性可能会在中断期间被修改,从而引发线程安全问题。当多个线程访问一个对象,如果不用考虑这些线程在运行时的调度/交替,也不需要额外的同步,调用行为都可以获得正确的结果,那这个对象是线程安全的,其代码本身封装了正确性保障手段(互斥同步)。线程安全根源在于共享数据。
1)不可变Immutable:不可变的对象(string/final/Number类型)永远不会在多个线程处于不一致的状态。如果共享数据是一个基本数据类型,可以声明为final;对象类型,可以保证对象行为不会对其状态产生影响,比如把对象中的状态变量都用final修饰。
2)绝对线程安全:基本不存在。
3)相对线程安全:通常说的线程安全都是相对线程安全;保证对象的单独操作是线程安全的,但对于连续操作,则仍然可能需要调用端用同步手段保证正确性。如Vector/HashTable,他的add/get/size方法都用synchronized修饰;
4)线程兼容:通常说的非线程安全,但通过调用端的同步手段能保证正确性,如ArrayList和HashMap。
5)线程对立:基本不存在,无论调用端怎么做,都无法在多线程中并发使用的代码。
五、线程安全的实现方法
1. 互斥同步 mutex(mutual exclusion)&synchronization
常用。同步指变量同一时刻只被一个线程使用;互斥是实现方式(临界区/互斥量/信号量),是一种悲观并发策略。
java通过synchronized实现互斥同步,编译后在同步块前后形成monitorenter/monitorexit字节码指令。字节码指令参数reference表示锁定对象。若synchronised明确指定对象参数,则该对象作为reference;如果synchronized修饰的是实例/类方法,则相应的对象实例/class对象就是锁对象。执行monitorenter指令时,尝试获取对象的锁,如果对象没有被锁定或者当前线程已经持有对象锁(可重入),就把锁的计数器加1;执行monitorexit时锁计数器减1,计数为0时释放锁。获取对象锁失败,则当前线程阻塞,直到对象锁被另一个线程释放。
互斥同步是重量级操作,阻塞/唤醒线程需要从用户态切换到内核态。确实必要才使用synchronized,虚拟机优化如在通知OS阻塞线程之前加入自旋等待。
2. 非阻塞同步
随着硬件指令集发展,基于冲突检测的乐观并发策略:先操作,没有其他线程争用共享数据则操作成功;有竞争则采取补偿措施如重试。
1)testAndSet;2)getAndIncrement:调用了CAS,原子类具有该方法,原子类提供的加减操作都是线程安全的;3)swap;4)compareAndSwap/CAS:3个操作数:内存位置V/旧的预期值A/新值B。CAS指令执行时,如果V==A,则V=B,返回V旧值;否则不更新,返回V旧值。具有ABA问题,如果A改为B后又改回A,则无法检测到修改;5)加载链接/条件存储
3. 无同步方案
不涉及共享数据的方法就不需要同步保证安全。
1)可重入代码/纯代码:可在代码执行的任何时刻中断,转而执行另一段代码,而在控制权返回后不会出现错误;用到的状态量都由参数传入。
2)线程本地存储:如果一段代码中需要的数据必须和其他代码共享,那就看这些代码能否在一个线程中执行,比如消费队列的架构模式/web交互模型的一个请求对应一个服务器线程;线程提供ThreadLocal用于存储本线程对象。
六、锁优化
1)适应性自旋:让等待锁的线程稍等一下,不放弃处理器的执行时间,执行一个忙循环(自旋);锁占用时间短的情况下优化效果好。自适应意味着自旋时间不固定,而是由上一个锁上的自旋时间和锁拥有者的状态来决定。
2)锁消除:对代码上要求锁,但经过逃逸分析判断数据不会被其他线程访问/竞争的锁进行消除,发生在JIT优化。
3)锁粗化:编写代码时,推荐同步块尽可能小,从而等待锁的线程能尽快拿到锁。对于反复加锁/释放锁的操作,会采用锁粗化的优化方式。
此外还有轻量级锁和偏向锁。