基本概念
程序: 程序是一个静态的概念,指的是可运行的指令或代码包。如jar包,exe安装文件等。
进程: 进程相比程序是动态的概念,是运行中的程序。进程与进程之间相对独立。
线程: 线程是程序执行过程中的不同路径,他们共享进程的资源。Java程序就是主线程main和子线程协同合作完成的。
并发: 同一时段内,可以执行多个任务,不一定是同时执行,可以是交替执行。
并行: 单位时间内,可以同时执行多个任务。
下图简单展示了并发与并行的区别,单核并发的每个时间点仅有一个任务在运行,只是通过在时间段内快速切换任务,感官上造成任务在同时执行的现象,实际上多个任务是在时间上分割后串行运行的。而多任务并行的每个时间点上都有多个任务在运行,是真正的同时执行!
线程生命周期
状态切换过程:Thread被new出来之后便进入初始New状态,调用start()方法后才会进入到运行Runable状态,但此时线程不一定马上被运行(Ready状态),需要被系统CPU调度后方能正式运行(Running状态)。线程执行wait()/join()方法后,会进入等待Waiting状态,此时线程会进入等待队列,永远都不会被系统调用到,除非其他线程执行了notify()方法将其唤醒,等待队列中的线程才能重新进入Runnable状态。而调用sleep(long)/wait(long)方法,线程会进入Timed_waiting超时等待状态,当设定的等待时间到达后,便能自行恢复到Runnable状态。当线程执行到synchronized方法或代码块时,若无法获得对象锁(锁被其他线程抢先占据),则会进入同步队列,线程的状态也会置为Blocked阻塞状态,直到该线程争抢到对象锁,才能进入Runnable状态。线程正常执行完后,便会成为Terminal结束状态,该状态是不可逆的。
在线程状态的切换中的三个队列:
调度队列: 是指Runnable状态的线程未获得系统的调度时所处的队列。当系统调度到该线程,便从队列中取出。
等待队列: 是指线程执行了wait()后所处的队列。该队列中的线程除非被其他线程唤醒,否则会一直处在队列中无法运行。
同步队列: 也可以叫阻塞队列,是指多个线程执行synchronized同步代码块时,没能获取到锁的那些线程所处的队列。待当前执行线程释放锁之后,队列中的线程才可重新争抢锁,来获得从队列中被调起的资格。
线程间通信的方式
- 同步式。 属于共享内存机制。使多个线程需要访问同一个共享变量,获得锁者方可执行。即synchronized加锁同一对象的机制。
- while轮询式。 通过在while循环中设置条件,来切换不同的线程的执行。但是该方式在条件不符时,一直在空转,浪费CPU资源。
- wait/notify式。 线程间通过wait()和notify()方法来切换当前执行的线程。该方式若是调用notify的时机不恰当,也会造成程序假死。
- 管道通信式。 属于消息通信机制。借助于java.io.PipedInputStream 和 java.io.PipedOutputStream类,在线程间建立消息传输管道,来实现线程间的通信。
Java内存模型与 volatile & synchronized
正因为JMM的这些特性,在并发场景下可能造成数据不一致。所以为了解决数据安全的问题,Java的编程模型中加入了volatile和synchronized等关键字,以满足原子性、可见性(缓存一致性)、有序性的原则。
volatile。 只可以用来修饰变量,被该关键字修饰的变量视为不稳定变量,线程在操作该变量时,每次都会从主内存中重新读取,就这保证了主内存和线程本地内存的一致性。另外volatile还会禁止字节码级的指令重排序,从而保证有序性!但是它没法保证操作的原子性。主要的应用场景是解决多线程间变量的一致性问题。
synchronized。 可以用来修饰方法和代码块,它相比volatile要重一些,而且可能造成线程的阻塞,但是synchronized既可以保证数据的可见性,又可以保证数据的原子性(借助字节码指令monitorenter和monitorexit来控制)。主要的应用场景是解决多线程之间访问资源的同步问题。
锁
- Markword
对象头包括:标记字Markword、类标识Klassword和数组长度(可选)。标记字Markword记录了锁状态、对象年龄、类的hashcode等信息;类标识Klassword是一个指针,指向方法区中类的元信息,表示了当前类的实例所属的Class;数组长度是可选的,仅当对象是数组时存在,记录了数组的长度。
对象体记录的是类定义的成员变量和值的信息,其实际的类型和数量决定了具体的长度。
对齐字节的存在是为了把对象的容量补齐成便于系统内存快速访问的块状。将对象的总长度对齐为可被8整除的位数。
这里我们重点对markword标记字展开进行说明。如上图所示,根据markword中64位状态位的不同,对象可分为5种状态:
- 无锁正常状态。此时markword中记录了对象的hashcode(在偏向锁,轻量锁,重量锁状态时,hashcode会被转移到Monitor中),对象的年龄,偏向锁位为0,锁状态位为01。
- 偏向锁状态。此时偏向锁位为0,锁状态位为01,并且记录了当前获取偏向锁的线程ID。
- 轻量级锁状态。此时锁状态位为00,其他位记录了栈中锁记录的指针。
- 重量级锁状态。此时锁状态位为10,其他位记录了对象监视器Monitor的指针。
- GC标记。GC状态标识,标识对象可以被回收(?存疑?)。
- 锁升级
根据线程的竞争情况,Java中的锁从低到高会逐渐经历四种状态的切换和升级,此过程不可逆,即锁不可以降级:
【无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁】
- 对象新创建时,处于无锁状态。
- 偏向锁。当第一个线程来竞争锁时,锁对象偏向于它,会将该线程ID记录到markword中。这样当该线程执行同步代码时,便无需做任何的检查和切换,相当于无同步限制,避免了旧式的同步锁从用户态切换内核态申请互斥量的繁杂流程,大大提高了效率。
- 当有超过一个线程来竞争锁时,锁会自动升级为轻量级锁。轻量级锁采用自旋(CAS)的方式,会占用CPU资源,但无需切换内核态进行操作,因此在锁竞争不是特别激烈的情况下(自旋不超过10次/自旋线程不超过CPU core的一半,之后有进一步优化成自适应自旋),可以有效地提升同步效率。
- 当锁的竞争情况更加激烈时,轻量级锁会导致更多的等待和CPU资源占用,此时轻量级锁会升级为重量级锁,需要切换到内核态申请互斥量,通过monitor来实现线程间的同步。此时markword也会指向对象监视器Monitor的指针。
每一个线程在准备执行同步代码获取共享的锁对象资源时经历如下:
1. 检查markword中存放的threadID是否为自己,是则当前线程处于偏向锁状态;
2. 若不是自己的threadID(且不为空),需要进行锁升级。此时通过CAS方式进行偏向锁的撤销,这时需要等待全局安全点(safe point),通知当前占用锁的线程暂停,并将markword中的threadId置为空。
3. 进入轻量级锁状态后,两个线程开始进行CAS操作,把对象的hashcode复制到自己线程栈的存储锁的记录空间,然后竞争着将对象的markword中的ptr_to_lock_record改为指向自己线程栈锁记录的地址。
4. 上一步中修改成功的线程获取到锁,失败的线程进行自旋等待。
5. 自旋线程在限定的条件下(自旋时间or自旋线程的总量),若获取到锁,则继续处于轻量级锁状态。
6. 若自旋失败,则升级锁为重量级锁。此时所有自旋线程进入阻塞队列,等待锁释放后被唤醒。
另外,关于锁的升级过程,并不是一定要全部经历的,可以根据具体的业务需要进行进一步的优化。如果业务场景中锁竞争的情况一直很激烈,可以禁用偏向锁(默认是打开的),以减少偏向锁的申请与撤销过程带来的性能损耗。
-
synchronized VS ReentrantLock
- 可重入性。 synchronized和ReentrantLock都是可重入锁。即线程在获取对象锁之后,还可以再次获取该锁继续执行。可重入锁是为了避免线程多次获取同一对象锁,或父子类调用重写的同步方法时,造成的死锁问题。
- 实现方式。 synchronized的锁是通过JVM实现的(JVM字节码级的优化实现),并不直接暴露给编程人员使用;但是ReentrantLock有提供相应的API编程接口(lock()与unlock()方法结合try/catch语句块使用),可供编程人员自定义调用过程。
- 其他特性。 ReentrantLock具备更加丰富的功能:支持公平锁;等待可中断;支持绑定条件的指定线程等待/唤醒机制。
- 性能。 性能相近,不作为选型的参考标准。
-
乐观锁 VS 悲观锁
乐观锁和悲观锁其实是处理并发可能情况的一种宽松/谨慎的态度。乐观锁,即读取时乐观的认为不会有并发问题,所以不加锁,只在修改时加锁。这种情况引发一致性问题,但效率更高,自旋锁(CAS机制)就是一种乐观锁。而悲观锁,即认为随时都有并发问题,因此无论读取还是修改,均会进行加锁的操作。尽管一致性得到了保证,但是锁操作必然造成性能的下降。
线程池
我们知道线程的创建需要通过操作系统来执行和调度,且生命周期从开发到结束时不可逆的,结束后便会销毁,再次使用需要重新创建。而线程池是一种池化设计,目的是:
1 减少损耗,提升利用率。重复利用已创建的线程,提升线程的响应速度,并减少线程创建和销毁造成的资源损耗。
2 提供统一管理。通过统一管理,控制线程的使用量,监控线程的状态,开放统一调度和优化的方法。
-
Executor 框架
- 主要构成
- 任务(Runnable /Callable)。自定义任务需要实现的 Runnable 接口 或 Callable接口。
- 任务的执行(Executor)。调用execute()或submit()方法执行任务。submit()方法会返回一个FutureTask对象。
- 异步计算的结果(Future)。
- 执行顺序
- 主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。
- 把创建完成的实现 Runnable/Callable接口的 对象直接交给 ExecutorService 执行。
- 如果ExecutorService执行submit()方法,将返回一个实现Future接口的对象。
- 主线程通过执行 FutureTask.get()方法来等待任务执行完成,或者调用FutureTask.cancel()取消任务。
- 主要构成
-
ThreadPoolExecutor 线程池类
- 核心参数
- corePoolSize:核心线程数,即最小可以同时运行的线程数。
- maximumPoolSize:当任务队列满载后,最大可以同时运行的线程数。
- workQueue:任务队列。当前运行线程大于核心线程数时,新的任务请求会暂时在队列中等待。
- keepAliveTime:队列中等待的线程超时销毁的等待时间。
- unit:keepAliveTime 参数的时间单位。
- handler:饱和策略,即当当前运行线程数达到maximumPoolSize,且等待的任务队列也满载时,线程池接受请求的策略。一般有:DiscardPolicy(直接丢弃)、AbortPolicy(抛出异常,拒绝处理)、CallerRunsPolicy(转交主线程执行,有延迟但保证任务请求不丢失)、DiscardOldestPolicy(丢弃等待队列中最早的任务)。
- 常见线程池
- FixedThreadPool。可重用固定线程数的线程池,即corePoolSize 和 maximumPoolSize相同。
- SingleThreadExecutor。单线程的线程池。,即corePoolSize = maximumPoolSize = 1。
- CachedThreadPool。按需创建新线程的线程池。即corePoolSize = 0,maximumPoolSize = Integer.MAX.VALUE。
- ScheduledThreadPoolExecutor。按给定延迟时间或定期时间来执行任务。包含一个优先级队列PriorityQueue,根据执行所需时长升序。
- 核心参数
参考资料:(文章仅做交流学习,侵权即删!!)