[TOC]
开篇
首先需要知道什么是线程?什么是进程?它们2个有什么区别?
- 进程是资源的分配的一个独立单元,而线程是CPU调度的基本单元
- 同一个进程里可以包含多个线程,并且线程共享整个进程资源
- 执行任务上,线程是进程的轻量级实现,进程内至少有一个线程,线程的创建和销毁比进程要快得多,操作系统中的执行功能都是创建线程去完成的。
在Java开发中,线程是一个非常重要的概念,所有的应用程序都基于这个重要的角色,而线程多起来不可避免就会带来一些安全相关的问题,以下:
1. 与线程相关的一些概念
- 线程安全:
线程安全的核心就是 正确性,这个正确性就是各个类的行为和其规范完全一致,当多个线程同时访问某个类时,不管运行时环境采⽤用何种 调度⽅方式或者这些线程将如何交替执行,并且在主调代码中不不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。 --《并发编程实战》- 共享资源:
共享意味着变量能够被多个线程同时访问,系统中的资源是有限的,不同的线程对资源有着相同的使用权。资源的有限和使用上的公平性就意味着需要竞争,竞争就会引发线程安全问题。- 线程同步:
线程同步的核心概念就是协同
,让操作某一个共享变量的各个线程按照正确的顺序去操作。过程理解为,当一个线程正在操作某个共享的变量时,在没有结束返回之前,其他请求操作该变量的线程将会被阻塞不能操作,保证数据的完整性。线程同步机制有临界区、互斥量、事件、信号量
四种方式。- 四种同步手段
a.临界区
:通过多线程串行化访问公共资源,速度快,适合控制对数据的访问,在任一时刻,只能有一个线程去访问公共资源,其他试图访问该资源的线程将会被挂起,直到进入临界区的线程执行完临界区释放才能争夺。所谓的临界区其实就是访问共享资源的程序片段。临界区的调度法则为:a.如果有很多线程都请求某个共享资源,只能有一个进入。b.任何时候,处于临界区的线程不可能多余1个,线程一旦占用可再次重入。c.进去临界区的线程要在有限的时间内退出,以便于其他线程进入。如果线程不能进入自己的临界区,就让出CPU,避免出现忙则等待的线程。
b.互斥量
:采用对象互斥的机制,只有拥有互斥对象的线程才能访问公共资源,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享。
c.信号量
:它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。
d.事件
:通过通知操作的方式来保持线程的同步,还可以方便实现对多个线程的优先级比较的操作.- 几种特殊性质:
a.原子性:一个操作不可分割,同生共死
b.可见性:线程局部缓存中修改的数据主存中立马更新确保其它线程可以看到这次修改
c.有序性:变量在并发情况下的执行结果和单线程中的执行结果一样,不会因为重排序而导致结果不同。volatile、final、synchronized,显式锁都可以保证有序性- 同步、异步、并发、并行
a.同步:发出一次调用,得到结果后返回结果
b.异步:发出一次调用,直接返回,不需要知道结果
c.并发:一个时间段内,有几个程序在同一个CPU上执行,但是任意一个时间点都只有一个真正执行
d.并行:一个时间段内,有几个程序在几个CPU上执行,任意一个时刻点上,多个程序都在运行,互不干扰。
同步异步想想刷盘、并发并行想想咖啡机
2. Synchronized如何实现线程同步
2.1 synchronized介绍
synchronized
是 Java 中的关键字,是一种同步锁,它可以修饰一个代码块或者一个方法,作用是当一个线程访问被它修饰的代码块(方法)时,其它试图访问该对象的线程将会被阻塞,直到先到的线程使用完了自动释放,这是从应用程序的角度去看的。从线程的角度去看就是它可以保证方法或者代码块在运行时同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。用法很简单,会Java开发基础的都知道,这里就不贴代码了。
2.2 synchronized的实现原理
首先,每个对象都可以作为锁(通过Java对象头和 Monitor实现了这把锁),这是 synchronized
实现同步的基础,它的场景和对应的锁如下:
- 普通同步方法,锁是当前实例对象
- 静态同步方法,锁是当前类的class对象
- 同步方法块,锁是括号里面的对象
而对于同步代码块和同步方法 synchronized
修饰时它们的底层也是有区别的,同步代码块是使用 monitorenter
和 monitorexit
指令实现同步的。而同步方法(JVM层)依靠的是方法修饰符上的ACC_SYNCHRONIZED
实现的。
- 同步代码块:
monitorenter
指令插入到同步代码块的开始位置,monitorexit
指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter
都有一个monitorexit
与之对应。任何对象都有一个Monitor
与之相关联,当一个Monitor
被持有以后,他将处于锁定状态。线程执行到monitorenter
指令时,将会尝试获取对象所对应的Monitor
所有权,即尝试获取对象的锁。- 同步方法:
synchronized
方法则被翻译成普通的方法调用和返回指令如:invokevirtual
、return
指令,在JVM字节码层面并没有任何特别的指令来实现被synchronized
修饰的方法,而是在Class文件的方法中将该方法的access_flags
字段中的synchronized
标志位设置为1,表示该方法是同步方法,并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass作为锁对象accessFlags
是类和方法的访问标志,总共16bits
2.3 Java对象头和Monitor
- 对象头
synchronized
用的锁是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word
(标记字段)、Klass Pointer
(类型指针)
Klass Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。Mark Word用于存储对象自身的运行时数据,如哈希吗(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向ID、偏向时间戳等等,Java对象头一般占用2个机器码。但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组的长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行而发生变化- Monitor
Moniter可以理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
互斥:一个Moniter锁在同一时刻只能被一个线程占用,其他线程无法占用
信号机制(signal):占用Moniter锁失败的线程会暂时放弃竞争并等到某个条件为true,当该条件成立后,当前线程会通过释放锁通知正在等待这个条件遍历的其他线程,让其可以重新竞争锁。
与一切皆对象一样,所有的Java对象是天生的Moniter
,每一个Java对象都有称为Moniter
的潜质,因为在Java的设计中,每一个Java对象都含有一把看不见的锁,称为内部锁或者Moniter
锁。Monitor Record是线程私有的数据结构,每一个线程都有一个可用Monitor Record列表,同时还有一个全局的可用性列表。每一个被锁住的对象都会和一个Monitor Record关联(对象头的Mark Word中的LockWord指向Monitor的起始地址),Monitor Record中有一个Owner字段,存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用
2.4 synchronized 锁优化
在JDK6之前,它是一个重量级锁,非常不推荐使用,因为效率很低,在JDK6之后对它进行了一波优化,分析下:在JVM中
monitorenter
和monitorexit
字节码依赖于底层的操作系统的Mutex Lock
来实现的但是由于使用Mutex
需要将当前线程挂起并从用户态切到内核态来执行,这种代价是非常昂贵的,然后在大部分的情况下。同步方法是运行在单线程环境,如果每次都调用Mutex Lock
那么将严重的影响程序的性能。因此JDK6对锁的实现引入了大量的优化,如自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
2.5 几种锁
2.5.1 自旋锁
我们知道,线程的堵塞和唤醒,需要CPU从用户态转为核心态。频繁的堵塞和唤醒对CPU来说是一件负担很重的工作,势必给系统的并发带来很大的压力。同时,对象锁的锁状态只会持续很短的一段时间,为了这一段很短的时间频繁的堵塞和唤醒是不值得的,所以引入自旋锁。自旋等待不能替代堵塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就很高,反之如果长时间不释放锁,自旋的线程一直在浪费资源。
- 定义:自旋锁就是当共享资源正在被占用的时候,如果过来一个请求共享资源的线程,就让这个线程等待一段时间,不会被立刻挂起,看持有锁的线程是否会很快释放锁。
- 如何自旋? 执行一段无意义的循环即可。为了防止线程长时间占用资源得不到锁,自旋等待有一个限度,如果超过了定义的时间仍然没有获取到锁,应该被挂起。在JDK6中默认次数为10次。但是这样的话系统如果很多线程都是等刚退出就释放了锁等情况,所以这个数字直接定死不好,定大了影响资源,所以JDK6中引入了自适应的自旋锁,让虚拟机去决定这个限度
2.5.2 适应自旋锁
所谓的自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
- 线程如果自旋成功了,那么下次自旋的次数会更多,因为虚拟机认为既然上次成功了,那么此次自旋也很可能会再次成功,那么它就会允许自旋等待持续的次数更多。(成功的好的更好)
- 反之,如果对于某个锁,很少有自旋能够成功的,那么在以后自旋的时候会减少自旋的次数甚至直接省略自旋的过程,以免浪费处理器资源。(差的直接失败吧,不要浪费资源)
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机变得越来越聪明。
2.5.3 锁消除
- 痛点:根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
- 原理:JVM在编译时通过对运行上下文的描述,去除不可能存在共享资源竞争的锁没通过这种方式消除无用所,即删除不必要的加锁操作,从而节省开销
- 使用:逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和XX:+EliminateLocks(锁消除必须在-server模式下)开启
- 补充:在JDK内置的API中,如StringBuffer、Vector、Hashtable都会存在隐性加锁操作,可消除。
public class SynchronizedDemo {
public static void main(String[] args) {
SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
System.out.println(System.currentTimeMillis());
for(int i = 0;i<100000;i++){
synchronizedDemo.append("yupao","rch");
}
System.out.println(System.currentTimeMillis());
}
public void append(String str1,String str2) {
//由于StringBuffer对象被封装在方法内部,不可能存在共享资源竞争的情况
//因此JVM会认为该加锁是无意义的,会在编译期就删除相关的加锁操作
//还有一点特别要注明:明知道不会有线程安全问题,代码阶段就应该使用StringBuilder
//否则在没有开启锁消除的情况下,StringBuffer不会被优化,性能可能只有StringBuilder的1/3
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
}
2.5.4 锁粗化
在使用同步锁的时候,需要让同步块的作用范围尽可能小:仅在共享数据的实际作用域中才进行同步。这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。但是如果一系列的连续加锁解锁操作会导致不必要的性能消耗,所以引入了锁粗化这个概念,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。例如for循环中StringBuffer.append()操作,每次add的时候都需要加锁,JVM检测到同一个对象连续加锁、解锁操作会合并成一个更大范围的加锁、解锁操作,也就是加锁和解锁会移动到for循环之外。
/**
* StringBuffer是线程安全的字符串处理类
* 每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁
*/
public void append(){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("123");
stringBuffer.append("456");
stringBuffer.append("145");
}
2.5.5 锁的升级
升级路线为:无锁->偏向锁->轻量级锁->重量级锁
- 从JDK6开始,锁一共有四种状态:无锁状态、偏向锁状态、轻量锁状态、重量锁状态
- 锁的状态会随着竞争情况逐渐升级,锁允许升级但不允许降级
- 不允许降级的目的是提高获取锁和释放锁的效率
2.5.6 重量级锁
重量级锁通过对象内部的监视器(Monitor)实现的,其中Monitor的本质是,依赖于底层操作系统的
Mutex Lock
实现,操作系统实现线程之间的切换,需要从用户态到内核态的切换,切换成本比较高。这也是为啥 synchronezed 是重量级锁的原因
2.5.7 轻量级锁
引入轻量级锁的主要目的,是在没有多线程竞争的前提下,减少重量级锁使用操作系统产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁,导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁:
- 获取锁
a. 判断当前对象是否处于无锁状态?若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,否则执行3
b. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正。如果成功,表示竞争到锁,则将锁标志位变成00
(表示此对象处于轻量级锁的状态),执行同步操作,如果失败,执行步骤3
c. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是,则表示当前线程已经持有当前对象的锁,则直接执行同步代码块。否则,只能说明该锁对象已经被其他线程抢占了,当前线程便开始尝试使用自旋来获取锁,若自旋后没获取锁,此时轻量级锁会升级为重量级锁,锁标志位变成10
,当前线程被堵塞。- 释放锁:释放锁也是通过CAS进行的
a. 取出在获取轻量级锁保存在Displaced Mark Word中的数据
b. 使用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功则表示释放锁成功,其实无论是否释放成功都会唤醒被挂起的线程重新争夺锁,访问同步代码块。CAS操作失败说明有其他线程尝试获取锁,需要释放锁的同时唤醒被挂起的线程。如图,00指的是无偏向锁,01指的是无锁状态。
c.注意:对于轻量级锁,其性能提升的依据是:对于绝大部分的锁,在整个生命周期内都是不会存在竞争的,如果打破了这个依据还有额外的CAS操作,在多线程的情况下,轻量级锁比重量级锁慢。
2.5.8 偏向锁
引入偏向锁的主要目的是:为了在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。轻量级锁的加锁和释放锁需要依赖多次的CAS操作,Mark Word的数据结构为:线程ID、Epoch(偏向锁的时间戳)、对象分带年龄、是否是偏向锁(1)、锁标识位(01),我们只需要检查是否为偏向锁、锁标识以及ThreadID即可,流程如下:
一:获取偏向锁
- 检测Mark Word是否为可偏向锁状态,即是否为偏向锁的标识位为1,锁标识位为01
- 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是执行5,否则执行3
- 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,执行步骤5,否则执行4
- 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被堵塞在安全点的线程继续往下执行同步代码。
- 执行同步代码块
二:撤销偏向锁
偏向锁的释放采用了一种只会竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争,偏向锁的撤销需要等待全局安全点(这个时间点是没有正在执行的代码),步骤如下:
- 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态
- 撤销偏向锁,回复到无锁状态(01)或者轻量级锁的状态。
最后唤醒暂停的线程
总结
这部分知道了同步和异步的区别、也知道了并发和并行的区别,同时也简单了解了线程和进程之间的区别。这对后面的学习还是有一些帮助的,既然多线程的情况下会涉及到线程安全,那么线程安全一般都是通过锁来解决的,后面介绍了后续的 synchronized
的关于锁的优化,实现了效率上的提升。
网友评论