Potato
Potato copied to clipboard
Java Thread
Java Thread
[TOC]
这篇文章了解一下 多线程 相关的知识。
线程底层原理
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。所以明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一。
用户态和内核态的概念
内核态:CPU可以访问内存所有数据, 包括外围设备, 例如硬盘, 网卡. CPU也可以将自己从一个程序切换到另一个程序
用户态:只能受限的访问内存, 且不允许访问外围设备. 占用CPU的能力被剥夺, CPU资源可以被其他程序获取
用户态与内核态的切换
用户态程序切换到内核态, 但是不能控制在内核态中执行的指令。
这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)
工作流程:
- 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈(stack frame), 以此表明需要操作系统提供的服务.
- 用户态程序执行陷阱指令
- CPU切换到内核态, 并跳到位于内存指定位置的指令, 这些指令是操作系统的一部分, 他们具有内存保护, 不可被用户态程序访问
- 这些指令称之为陷阱(trap)或者系统调用处理器(system call handler). 他们会读取程序放入内存的数据参数, 并执行程序请求的服务
- 系统调用完成后, 操作系统会重置CPU为用户态并返回系统调用的结果
线程基本介绍
线程:一个基本的 CPU 执行单元,程序执行的最小单位
- 比进程更小,可独立运行
- 组成:线程ID + 程序计数器 + 寄存器集合 + 堆栈
- 与其他线程共享进程所拥有的全部资源
作用:减少程序在并发执行时所付出的时空开销,提高系统的并发性能。
拥有就绪,阻塞,运行三种基本状态。
线程控制模块
当我们构造一个线程,java虚拟机会在内存中生成一个线程控制块,其中包括PC寄存器、Java栈、本地方法栈,这是每个线程独自拥有的,互不干涉。
PC计数器:存放当前正在被执行的字节码指令(JVM指令)的地址。说白了,就是PC计数器用来记住这个线程被执行到那一步了(方便下次继续执行)。
Java栈:这个栈中存放着一系列的栈帧(Stack Frame),JVM只能进行压入(Push)和弹出(Pop)栈帧这两种操作。每当调用一个方法时,JVM就往栈里压入一个栈帧,方法结束返回时弹出栈帧。每个栈帧包含三个部分:本地变量数组、操作数栈(操作数栈中存放方法执行时的一些中间变量,JVM在执行方法时压入或者弹出这些变量。其实,操作数栈是方法真正工作的地方,其中我们定义的各种基础数据类型的变量,和对象的引用变量都在操作数栈的内存中储存。当一个函数执行完后,它对栈内存的占用也会被释放,供下一个函数使用)、方法所属类的常量池引用。
**本地方法栈:**这个栈用来存放本地语言(如C或者C++代码)的方法调用信息,我们知道java是通过在操作系统的基础上虚拟出一层环境(称为JRE)来运行我们的java 程序的,编写操作系统的语言多数时候并不是java(例如windows和linux都是C语言编写的),当程序通过JNI(Java Native Interface)调用本地方法(如C或者C++代码)时,就根据本地方法的语言类型建立相应的栈。
线程基本方法介绍
基本的知识如下:
-
每个对象都有一个锁来控制同步访问,Synchronized 关键字可以和对象的锁交互,来实现同步方法或者同步块。sleep() 方法正在执行的线程主动让出 CPU (然后 CPU 就可以去执行其他任务),在 sleep() 指定时间后 CPU 再回到该线程继续往下执行(sleep 方法只是让出 CPU,而不会释放同步资源锁);
/而 wait() 方法则是当前线程让自己暂时退出资源同步锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用 notify() 方法之后,之前调用 wait() 的线程才会接触 wait 状态,可以参与竞争同步资源锁,进而得到执行的机会,
(注意:notify 只是让之前调用 wait 的线程有权利重新参与线程的调度)
-
sleep() 可以在任何地方调用;wait() 方法只能在同步方法或者同步代码块中调用。
package test
val lock = Object()
object MultiThread {
@JvmStatic
fun main(args: Array<String>) {
Thread1().start()
Thread2().start()
}
}
class Thread1 : Thread(Runnable {
synchronized(lock) {
(0 until 10).forEach {
if (it == 3) {
lock.wait()
}
println("${Thread.currentThread()} -- $it")
Thread.sleep(500)
}
}
}, "Thread1")
class Thread2 : Thread(Runnable {
synchronized(lock)
{
(0 until 10).forEach {
if (it == 4) {
lock.notify()
}
println("${Thread.currentThread()} -- $it")
Thread.sleep(500)
}
}
}, "Thread2")
sleep 和 yield 的区别
两个方法都是 Thread 类中的静态方法, 都会使得当前处于运行状态的线程放弃 CPU。
区别如下:
- sleep 使当前线程暂停一段时间,给其他线程运行的机会,不考虑优先级,不释放锁资源;yield 只会让位给优先级一样或者高于它的线程。
- 当线程执行 sleep 之后,线程进入睡眠状态,知道时间结束;而 yield 直接转到就绪状态。
正确停止线程
之前的 stop() 方法强制终止一个线程的执行,可能会造成数据不一致。
现在常用的是线程中断的方法:不会使线程立刻退出,而是给线程发送一个通知,告诉目标线程,需要退出。
使用Thread.interrupt()方法处理现场中断,需要使用Thread.isInterrupted()判断线程是否被中断,然后进入中断处理逻辑代码。
死锁
手写死锁代码:
package test
val A = Object()
val B = Object()
object DeathLock {
@JvmStatic
fun main(args: Array<String>) {
Thread3().start()
Thread4().start()
}
}
class Thread3 : Thread(Runnable {
synchronized(A) {
println("${Thread.currentThread()} has A lock: ${Thread.holdsLock(A)}")
try {
// 防止这个线程启动一下连续获得o1和o2两个对象的锁
Thread.sleep(1000)
} catch (e: Exception) {
}
synchronized(B) {
println("${Thread.currentThread()} has B lock :${Thread.holdsLock(B)}")
}
}
}, "Thread3")
class Thread4 : Thread(Runnable {
synchronized(B) {
println("${Thread.currentThread()} has B lock: ${Thread.holdsLock(B)}")
synchronized(A) {
println("${Thread.currentThread()} has A lock: ${Thread.holdsLock(A)}")
}
}
}, "Thread4")
一般来说:死锁的出现有四个条件:
- 互斥条件:一个资源每次只能被一个线程使用
- 请求和保持条件:一个线程因请求资源而阻塞,对已获得的资源保持不放
- 不剥夺条件:对线程已获得的资源,在未使用完之前, 不能强行剥夺
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
破坏其中一个条件即可解决死锁问题。
Synchronized
作用:被该关键字修饰的代码块,保证同一时刻最有只有一个线程执行
其他线程 必须等待当前线程执行完该代码块之后才能执行
解决多线程下的并发同步问题(阻塞性并发),保证线程安全。
原理
依赖 JVM 实现同步,底层通过一个监视器对象 monitor
对象完成,wati(), notify()
方法也依赖于此对象。
monitor 的本质依赖于底层操作系统的互斥锁
具体使用
用于修饰类的实例方法,代码块和静态方法
锁对象:
- 代码块:需要一个引用对象作为锁的对象
- 类的实例方法:当前类对象
- 静态方法:当前类的 Class 对象
其中对象锁和类锁的区别如下
类型 | 锁对象 | 锁的数量 | 表现形式 | 应用场景 |
---|---|---|---|---|
对象锁(含方法锁) | 实例对象 | 多个 | Synchronized 修饰的方法和代码块 | 控制方法之间的同步 |
类锁 | Class 对象 | 一个 | Synchronized 修饰的静态方法和静态代码块 | 控制静态方法(静态变量互斥)的同步 |
特点
- 保证有序性,原子性,和可见性
- 可重入性:对同一个线程获得锁后,在调用其他需同样锁的代码时可直接调用
- 重量级:操作系统实现线程切换需要从用户态切换到内核态,时间较长,因此效率低。
ReentrantLock
与 Synchronized 同样作为同步的方式。前者使用简单,不需要显示释放。
而后者需要解锁操作。
为什么引入 ReentrantLock:可重入,可中断,可限时,公平锁等特点
Volatile
首先 volatile 关键字的使用跟 java 内存模型,并发编程中的原子性,有序性和可见性相关。
通过 Synchronized 和 Lock 都能保证并发的三个特性。
java 内存模型
规定所有的变量都是存在主存中,每个线程都有自己的工作内存。线程相对变量的所有操作都必须在工作内存中进行,而不能直接对主存操作;并且每个线程不能其他线程的工作内存。
两层意思
一旦一个变量被 volatile 修饰之后,那么具备下述两层语义:
- 保证不同线程对这个变量进行操作是的可见性。即一个线程修改,另一个线程可见
- 禁止进行指令重排序
原理和实现机制
观察加入 volatile 关键字和没有加入 volatile 关键字所生成的汇编代码发现,加入之后,会多出一个 lock 前缀指令
lock 前缀指令实际上相当于一个内存屏障(内存栅栏),提供 3 个功能。
-
确保指令重排序不会把其后面的指令排到内存屏障之前的位置,也不会把前面指令排到内存屏障的后面;
即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
-
强制将对缓存的修改操作立即写入到主存
-
如果是写操作,会导致其他 CPU 中对应的缓存行无效
使用场景
使用前提:
- 对变量的写操作不依赖当前值
- 该变量没有包含在其他变量的不变式中
内存屏障
由于现代的操作系统都是多处理器.而每一个处理器都有自己的缓存,并且这些缓存并不是实时都与内存发生信息交换.这样就可能出现一个cpu上的缓存数据与另一个cpu上的缓存数据不一致的问题.而这样在多线程开发中,就有可能导致出现一些异常行为.
而操作系统底层为了这些问题,提供了一些内存屏障用以解决这样的问题.目前有4种屏障.
- LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
使用
- 通过 Synchronized关键字包住的代码区域,当线程进入到该区域读取变量信息时,保证读到的是最新的值.这是因为在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,而对数据的读取也不能从缓存读取,只能从内存中读取,保证了数据的读有效性.这就是插入了StoreStore屏障
- 使用了volatile修饰变量,则对变量的写操作,会插入StoreLoad屏障.
- 其余的操作,则需要通过Unsafe这个类来执行.