一、锁优化的思路和方法
锁优化是指:在多线程的并发中当用到锁时,尽可能让性能有所提高。一般并发中用到锁,就是阻塞的并发,前面讲到一般并发级别分为阻塞的和非阻塞的(非阻塞的包含:无障碍的,无等待的,无锁的等等),一旦用到锁,就是阻塞的,也就是一般最糟糕的并发,因此锁优化就是在堵塞的情况下去提高性能;所以所锁的优化就是让性能尽可能提高,不管怎么提高,堵塞的也没有无锁的并发底。让锁定的障碍降到最低,锁优化并不是说就能解决锁堵塞造成的性能问题。这是做不到的。
方法如下:
减少锁持有时间
减小锁粒度
锁分离
锁粗化
锁消除
二、减少锁持有时间
举例:
public synchronized void syncMethod(){
othercode1();
mutextMethod();
othercode2();
}
使用这个锁会造成其他线程进行等待,因此让锁的的持有时间减少和锁的范围,锁的零界点就会降低,其他线程就会很快获取锁,尽可能减少了冲突时间。
改进优化如下:
public void syncMethod2(){
othercode1();
synchronized(this){
mutextMethod();
}
othercode2();
}
三、减小锁粒度
将大对象,拆成小对象,好处是:大大增加并行度,降低锁竞争(同时偏向锁,轻量级锁成功率提高)
提高偏向锁,轻量级锁成功率
HashMap的同步实现( HashMap他是非线程安全的实现)
– Collections.synchronizedMap(Map m)(多线程下使用时:用该synchronizedMap封装方式先封装让他实现线程同步的)
– 返回SynchronizedMap对象
内部实现如下:就是实现对set与get进行加锁,进行互斥上同步,不管读还是写都会拿到这个互斥对象。他变成很重的对象,不管读还是写,都会互斥阻塞,读堵塞写,写堵塞读,当多个读和写时线程会一个一个的进来。
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
ConcurrentHashMap(高性能的hash表,他就是做了减少锁粒度的实现,他被拆分好像16个Segment,每个Segment就是一个个小的hashmap.。就是把大的hash表拆成若干个小的hash表。)
– 若干个Segment :Segment[] segments
– Segment中维护HashEntry
– put操作时• 先定位到Segment,锁定一个Segment,执行put
在减小锁粒度后, ConcurrentHashMap允许若干个线程同时进入
五、锁分离
就是把读堵塞写,写堵塞读,读读堵塞,写写堵塞就可以使用所分离;锁分离,就是读写锁分离,读不用改变数据,所以所有的读不会产生堵塞。当写的时候才去进行堵塞。一般读情况大于锁,所以使用读写锁会有所提高系统性能。如下图
1、 ReadWriteLock : 维护了一对锁,读锁可允许多个读线程并发使用,写锁是独占的。
根据功能进行锁分离
所有读写锁的实现必须确保写操作对读操作的内存影响。换句话说,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。
读写锁比互斥锁允许对于共享数据更大程度的并发。每次只能有一个写线程,但是同时可以有多个线程并发地读数据。ReadWriteLock适用于读多写少的并发情况。
读多写少的情况,可以提高性能(根据功能模块是进行不同锁,读锁跟读锁同时进入情况其实就属于无等待的并发,因此这种操作就是把堵塞的变成非堵塞的,性能就是有所改变)
锁 | 读锁 | 写锁 |
---|---|---|
读锁 | 可访问 | 不可访问 |
写锁 | 不可访问 | 不可访问 |
ReadWriteLock源码剖析:https://blog.csdn.net/qq_19431333/article/details/70568478
2、 读写分离思想可以延伸,只要操作互不影响,锁就可以分离
LinkedBlockingQueue LinkedBlockingQueue 用法:https://www.cnblogs.com/edgedance/p/7082078.html
– 队列
– 链表
思想也可以理解为:在forkjioning有所提到,就是任务work的偷窃,当线程执行自己的任务,和一个线程去盗取别人的任务,他们的任务队列中的数据他们是从两个不同的端去拿的,这就是热点分离基本思想,一个从头部拿,一个从尾部拿。如下:
头部和尾部之间的操作是不冲突的,所以可以进行高并发操作,当然当队列中只有一个数据情况就另当别论你了。
六、锁粗化
(一)、通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。
因此可以把很多次请求的锁拿到一个锁里面,但前提是:中间不需要的同步的代码块很很快的执行完。
1.举例如下:
public void demoMethod(){
synchronized(lock){
//do sth.
}
//做其他不需要的同步的工作,但能很快执行完毕
synchronized(lock){
//do sth.
}
}
改进优化如下:
public void demoMethod(){
//整合成一次锁请求
synchronized(lock){
//do sth.
//做其他不需要的同步的工作,但能很快执行完毕
}
}
2.举例如下:
for(int i=0;i<CIRCLE;i++){
synchronized(lock){
}
}
该进入下:
synchronized(lock){
for(int i=0;i<CIRCLE;i++){
}
}
七、锁消除
在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。
有时候对完全不可能加锁的代码执行了锁操作,因为些锁并不是我们加的,是JDK的类引用进来的,当我们使用的时候,会自动引进来,所以我们会在不可能出现在多线程需要同步的情况就执行了锁操作。在某些条件成熟下,系统会消除这些锁。如下:
public static void main(String args[]) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < CIRCLE; i++) {
craeteStringBuffer("JVM", "Diagnosis");
}
long bufferCost = System.currentTimeMillis() - start;
System.out.println("craeteStringBuffer: " + bufferCost + " ms");
}
public static String craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer(); //他就是实现的多线程同步功能
sb.append(s1); //这两个就是同步操作
sb.append(s2);
return sb.toString();
}
sb是线程安全的。但事实上sb他在栈空间引用的,他是局部变量,他就是在线程内部才会有的,在局部变量表中,只有一个线程可以执行他,其他线程是不可靠,能访问到他的因此对sb进行所有同步操作都是无意义的。
因此对些情况,虚拟机提供了一些优化,就是如下操作,虚拟机开启server模式
同时进行开启逃逸分析DoEscapeAnalysis,如果没有逃逸的就把锁去掉(EliminateLocks)。逃逸分析是指:看sb是否有可能逃出StringBuffer的作用域。变成sb公有的,全局的变量,变成其他线程可访问的了。
进行逃逸分析的执行时间,(同时加上去掉锁操作),
进行逃逸分析的执行时间,(没有加上去掉锁操作)。
server模式用法简单讲解:
与client模式相比,server模式的启动比较慢,因为server模式会尝试收集更多的系统性能信息,使用更复杂的优化算法对程序进行优化。因此当系统完全启动并进入运行稳定期后,server模式的执行速度会远远快于client模式,所以在对于后台长期运行的系统,使用-server参数启动对系统的整体性能可以有不小的帮助,但对于用户界面程序,运行时间不长,又追求启动速度建议使用-client模式启动。
未来发展64位系统必然取代32位系统,而64位系统中的虚拟机更倾向于server模式。
八、虚拟机内的锁优化
偏向锁
轻量级锁
自旋锁
1.首先看下:对象头Mark 详细讲解:https://blog.csdn.net/zhoufanyang_china/article/details/54601311
Mark Word,对象头的标记,32位 (对象头部保存一些对象的一些信息,32位是指系统的位数)
描述对象的hash、锁信息,垃圾回收标记,年龄
– 指向锁记录的指针
– 指向monitor的指针
– GC标记
– 偏向锁线程ID
2、偏向锁(偏心,就是偏向当前占有锁的线程,他的思想是悲观的思想,一般我们都是杞人忧天的,大多情况是没有竞争的,就可以使用偏向锁,可以对一个线程操作提高性能)
思想:那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步,退出同步也,无需每次加锁解锁都去CAS更新对象头,如果不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候需要锁膨胀为轻量级锁,才能保证线程间公平竞争锁。
在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。
(1.)大部分情况是没有竞争的,所以可以通过偏向来提高性能
(2.)所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程
(3.)将对象头Mark的标记设置为偏向,并将线程ID写入对象头Mark
(4.) 只要没有竞争,获得偏向锁的线程,在将来进入同步块,不需要做同步
(5.)当其他线程请求相同的锁时,偏向模式结束
(6.) -XX:+UseBiasedLocking
–默认启用
(6.) 在竞争激烈的场合,偏向锁会增加系统负担(每次偏向模式都会失败,因为线程竞争,就会是偏向锁结束;所以每一次都很容易结束偏向锁,就加大了偏向锁的每一次判断,偏向锁就没有任何效果)
public static List<Integer> numberList =new Vector<Integer>(); //Vector带有锁
public static void main(String[] args) throws InterruptedException {
long begin=System.currentTimeMillis();
int count=0;
int startnum=0;
while(count<10000000){
numberList.add(startnum);
startnum+=2;
count++;
}
long end=System.currentTimeMillis();
System.out.println(end-begin);
}
在系统起来时虚拟机默认启用偏向时间是4,因为开始的竞争是很激烈的。
3.轻量级锁(就是如果在偏向锁失败时,系统就会有可能去进行轻量级锁,目的是尽可能不要动用操作系统中层面的互斥,性能差,因为对于操作系统来说,虚拟机本身就是应用,所以我们在应用层面去解决线程同步问题。)
自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
Mark Word是对象头的一部分;每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息。二者属于JVM的基础内容,此处不做介绍。
当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。
思想就是:判断线程是否持有某个对象锁,去看他的头部是否设置了这个对象的mark值,如果有,就说明线程拥有了锁。
BasicObjectLock
– 嵌入在线程栈中的对象
普通的锁处理性能不够理想,轻量级锁是一种快速的锁定方法。
如果对象没有被锁定(判断步骤)
– 将对象头的Mark指针保存到锁对象中
– 将对象头设置为指向锁的指针(在线程栈空间中)
如下操作:在虚拟机层面去进行快速持有锁与非持有锁判断操作,其实就是CAS操作。cas成功,说明你持有锁,费则则没有。
lock->set_displaced_header(mark);
if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark))
{
TEVENT (slow_enter: release stacklock) ;
return ;
}
lock位于线程栈中
产生问题:
- 如果轻量级锁获取失败(CAS失败),表示存在竞争,升级为重量级锁(常规锁)
- 在没有锁竞争的前提下,减少传统锁使用OS(操作系统)互斥量产生的性能损耗
3.在竞争激烈时,轻量级锁会多做很多额外操作,导致性能下降
扩展CAS:
CAS:Compare and Swap, 翻译成比较并交换。
java.util.concurrent包中借助CAS实现了区别于synchronouse同步锁的一种乐观锁。
CAS操作包含三个操作数——内存位置(V),预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器将会自动将该位置值更新为新值,否则,不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。
通过以上定义我们知道CAS其实是有三个步骤的
1.读取内存中的值
2.将读取的值和预期的值比较
3.如果比较的结果符合预期,则写入新值
https://blog.csdn.net/liu88010988/article/details/50799978
https://blog.csdn.net/qq_35357656/article/details/78657373
4.自旋锁(可以防止在操作系统层面线程被挂起)当轻量级锁没有拿到失败时,他就有可能动用操作系统方面的互斥,有可能动用是指,他还可能进行自旋锁操作。
当竞争存在时,如果线程可以很快获得锁,那么可以不在OS层挂起线程,让线程做几个空操作(自旋);当拿不到锁时,不立即去挂掉线程,而是做空循环,尝试再去拿到锁,当别人释放锁时,你就可以拿到锁。避免线程在操作系统层面挂起。避免8万个时间周期的浪费。
JDK1.6中-XX:+UseSpinning开启 1.6可关闭和开启操作,
JDK1.7中,去掉此参数,改为内置实现 1.7则把他改为内置开启
如果同步块很长,自旋失败,会降低系统性能
如果同步块很短,自旋成功,节省线程挂起切换时间,提升系统性能
因此减少锁的持有时间也会增加自旋成功率。ConcurrentHashMap就可以使用这个自旋锁,hashmap的操作是非常快的,所以自旋等待的可能性就会提高。
5.偏向锁,轻量级锁,自旋锁总结(这些都是在虚拟机层面的优化,不是java层面的方式)
他们不是Java语言层面的锁优化方法,是虚拟机层面的方法
内置于JVM中的获取锁的优化方法和获取锁的步骤
– 偏向锁可用会先尝试偏向锁
– 轻量级锁可用会先尝试轻量级锁
– 以上都失败,尝试自旋锁
– 再失败,尝试普通锁,使用OS互斥量在操作系统层挂起 OS互斥量:
(1)、偏向锁、轻量级锁、重量级锁适用于不同的并发场景:
偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
重量级锁:有实际竞争,且锁竞争时间长。
另外,如果锁竞争时间短,可以使用自旋锁进一步优化轻量级锁、重量级锁的性能,减少线程切换。
如果锁竞争程度逐渐提高(缓慢),那么从偏向锁逐步膨胀到重量锁,能够提高系统的整体性能。
三种锁的详细解析:https://blog.csdn.net/zqz_zqz/article/details/70233767
https://blog.csdn.net/noble510520/article/details/78834224
6.一个错误使用锁的案例-对不变模式的数据类型进行加锁操作
public class IntegerLock {
static Integer i=0;
public static class AddThread extends Thread{
public void run(){
for(int k=0;k<100000;k++){
synchronized(i){
i++;
}
}
}
}
public static void main(String[] args) throws InterruptedException {
AddThread t1=new AddThread();
AddThread t2=new AddThread();
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
interge 是不变模式的,也就是i值不会发生变化,变化的是i的引用。static Integer i=0; 是不变的,Interge是不可变的,对他i++是不会改变的,因此这里i++实际的动作是对原始的int做操作,对Interge做++其内部是对他自动拆箱成int进行i++的,这时候改变的不是interge对象的值,而是改变i本身的引用,当i++时,会生成新的Interge,并复到i上,而不是把原来i进行操作,如果每一次都对i做同步,但不同的线程操作的i对象可能不是同一个i,第一个可能执行原来的i,下一个线程可能执行新的i对象。(可以用上面代码测试)
7.ThreadLocal用法案例
ThreadLocal跟锁是没有关系,ThreadLocal是最彻底的,可以把锁完全给替代的东西。
基本思想是:多线程中对有数据冲突的对象进行加锁操作,那么去掉锁的简单方法是,为每一个线程都提供一个对象的实例,不同的线程访问自己的对象。
他是线程局部的变量
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static class ParseDate implements Runnable{
int i=0;
public ParseDate(int i){this.i=i;}
public void run() {
try {
Date t=sdf.parse("2015-03-29 19:29:"+i%60); //sdf对象他不是线程安全的
System.out.println(i+":"+t);
} catch (ParseException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ExecutorService es=Executors.newFixedThreadPool(10);
for(int i=0;i<1000;i++){
es.execute(new ParseDate(i));
}
}
SimpleDateFormat被多线程访问
优化:线程安全的
static ThreadLocal<SimpleDateFormat> tl=new ThreadLocal<SimpleDateFormat>();
public static class ParseDate implements Runnable{
int i=0;
public ParseDate(int i){this.i=i;}
public void run() {
try {
if(tl.get()==null){
tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));//每一次要new一个对象
}
Date t=tl.get().parse("2015-03-29 19:29:"+i%60);
System.out.println(i+":"+t);
} catch (ParseException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ExecutorService es=Executors.newFixedThreadPool(10);
for(int i=0;i<1000;i++){
es.execute(new ParseDate(i));
}
}
为每一个线程分配一个实例
另外一个错误案例:他不会去维护每一个对象的拷贝,实际上tl.get()是把ThreadLocal对象指向同一个对象实例,对所有线程来说他还是同一个对象。
static ThreadLocal<SimpleDateFormat> tl=new ThreadLocal<SimpleDateFormat>();
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static class ParseDate implements Runnable{
int i=0;
public ParseDate(int i){this.i=i;}
public void run() {
try {
if(tl.get()==null){
tl.set(sdf);
}
Date t=tl.get().parse("2015-03-29 19:29:"+i%60);//这个还不是线程安全的,操作还是同一个线程,ThreadLocal指定的还是同一个对象,
System.out.println(i+":"+t);
} catch (ParseException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
ExecutorService es=Executors.newFixedThreadPool(10);
for(int i=0;i<1000;i++){
es.execute(new ParseDate(i));
}
}
如果使用共享实例,起不到效果
总结:对于工具等api对象类,数据库连接实例等希望对每个线程持单独有一个对象,就会减少线程的开销,比如SimpleDateFormat
不需要线程之间相互影响,不会产生冲突,就可以使用他。
ThreadLocal源码分析:https://www.cnblogs.com/eternityz/p/12238824.html
参考
原文:https://blog.csdn.net/gududedabai/article/details/80911855