java锁优化

Posted 未月廿三

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java锁优化相关的知识,希望对你有一定的参考价值。

一、锁优化的思路和方法

锁优化是指:在多线程的并发中当用到锁时,尽可能让性能有所提高。一般并发中用到锁,就是阻塞的并发,前面讲到一般并发级别分为阻塞的和非阻塞的(非阻塞的包含:无障碍的,无等待的,无锁的等等),一旦用到锁,就是阻塞的,也就是一般最糟糕的并发,因此锁优化就是在堵塞的情况下去提高性能;所以所锁的优化就是让性能尽可能提高,不管怎么提高,堵塞的也没有无锁的并发底。让锁定的障碍降到最低,锁优化并不是说就能解决锁堵塞造成的性能问题。这是做不到的。

方法如下:

 减少锁持有时间

 减小锁粒度

 锁分离

 锁粗化

 锁消除

二、减少锁持有时间

举例:

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位于线程栈中

产生问题:

  1. 如果轻量级锁获取失败(CAS失败),表示存在竞争,升级为重量级锁(常规锁)
  2. 在没有锁竞争的前提下,减少传统锁使用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

以上是关于java锁优化的主要内容,如果未能解决你的问题,请参考以下文章

10行代码理解Java锁消除

Java多线程锁的优化策略

锁机制及锁优化

java并发线程锁技术的使用

Java虚拟机--线程安全和锁优化

Java6对synchronized的优化-锁升级过程详细过程