锁
锁
大纲:
锁的原因和原理:
n 线程安全
n 对象头Mark
JVM内置的三种优化锁:
n 偏向锁
n 轻量级锁
n 自旋锁
编写代码时锁的调优:
n 减少锁持有时间
n 减小锁粒度
n 锁分离
n 锁粗化
n 锁消除
n 无锁
介绍一下传统的锁是重量级的,monitorenter[z1] 有可能让线程在OS(操作系统)层面挂起
线程安全
多线程网站统计访问人数
n 使用锁,维护计数器的串行访问与安全性
n 多线程网站统计访问人数,是否需要精确统计?如果不需要,可以不进行加锁。因为加锁操作会消耗系统的性能。
多线程访问ArrayList
代码:
public static List<Integer> numberList =new ArrayList<Integer>(); public static class AddToList implements Runnable{ int startnum=0; public AddToList(int startnumber){ startnum=startnumber; } public void run() { int count=0; while(count<1000000){ numberList.add(startnum); startnum+=2; count++; } } }
|
public static void main(String[] args) throws InterruptedException { Thread t1=new Thread(new AddToList(0)); Thread t2=new Thread(new AddToList(1)); t1.start(); t2.start(); while(t1.isAlive()[z2] || t2.isAlive()){ Thread.sleep(1); } System.out.println(numberList.size()); }
|
输出:
因为:ArrayList是可扩展的数组,它会自动的扩展数组的长度。再这个多线程当中,这个ArrayList不是线程安全的,当这个ArrayList用于实际存储的数组长度不够,它要扩展这个数组的时候,在扩展进行当中,其实这个ArrayList是处于一个不可用的状态,而这个时候又没有做一个多线程的保护,导致另外一个线程跑进来要往ArrayList中插入数据,这边长度扩展还没有完成,那边又要向里面插入数据,自然而然,数组下标就越界了。
对象头Mark(标记)
n Mark Word(标记词),对象头的标记,32位
n 描述对象的hash值、锁信息,垃圾回收标记,年龄
– 指向锁记录的指针
– 指向monitor(监视器)的指针
– GC标记
– 偏向锁线程ID
理解Java对象头与Monitor
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:
· 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
· 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
而对于顶部,则是Java头对象,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:
虚拟机位数 |
头对象结构 |
说明 |
32/64bit |
Mark Word |
存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit |
Class Metadata Address |
类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构
锁状态 |
25bit |
4bit |
1bit是否是偏向锁 |
2bit 锁标志位 |
无锁状态 |
对象HashCode |
对象分代年龄 |
0 |
01 |
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:
其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。
对象头包含两部分信息:Mark Word 和 元数据指针(Klass*):
Mark Word:
· instanceOopDesc中的_mark成员,允许压缩。它用于存储对象的运行时记录信息,如哈希值、GC分代年龄(Age)、锁状态标志(偏向锁、轻量级锁、重量级锁)、线程持有的锁、偏向线程ID、偏向时间戳等
元数据指针:
· instanceOopDesc中的_metadata成员,它是联合体,可以表示未压缩的Klass指针(_klass)和压缩的Klass指针。对应的klass指针指向一个存储类的元数据的Klass对象
执行new A()创建对象的时候,JVM native层里发生了什么。
首先,如果这个类没有被加载过,JVM就会进行类的加载,并在JVM内部创建一个instanceKlass对象表示这个类的运行时元数据(相当于Java层的Class对象)。到初始化的时候(执行invokespecial A::<init>),JVM就会创建一个instanceOopDesc对象表示这个对象的实例,然后进行Mark Word的填充,将元数据指针指向Klass对象,并填充实例变量。
根据对JVM的理解,我们可以想到,元数据—— instanceKlass 对象会存在元空间(方法区),而对象实例—— instanceOopDesc 会存在Java堆。Java虚拟机栈中会存有这个对象实例的引用。
对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头
HotSpot虚拟机的对象头包括两部分信息:运行时数据和类型指针。
运行时数据(Mark Word)
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
类型指针(元数据指针)
即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
(并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身,可参考对象的访问定位)
实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段的内容。无论是从父类中继承下来的,还是在子类中定义的,都需要记录下来。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oop,从分配策略中可以看出,相同宽度的字段总是分配到一起。
对齐填充
HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
偏向锁
n 大部分情况是没有竞争的,所以可以通过偏向来提高性能
n 所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程
n 将对象头Mark的标记设置为偏向,并将线程ID写入对象头Mark
n 只要没有竞争,获得偏向锁的线程,在将来进入同步块,不需要做同步
n 当其他线程请求相同的锁时,偏向模式结束(有竞争,偏向模式结束)
n -XX:+UseBiasedLocking(设置参数)
– 默认启用
n 在竞争激烈的场合,偏向锁会增加系统负担
偏向锁:会偏向于(偏心于)当前已经占有锁的这个线程,也就说如果这个线程它当时已经占有这个锁了,那么,当它再去试图获得这个锁的时候,它就会以最快的方式拿到这个锁,而不需要进行一些monitor(监视器)的操作。为什么会有偏向锁的产生,是因为我们认为在大部分程序里面,我们普遍认为,大部分情况下是没有锁竞争的,也就说,锁竞争的场合可能并不多见。因此,大部分情况下没有锁竞争,那这个锁放在那边就是没用的,那么因此,就可以使用偏向锁来提高性能。
虽然偏向锁在jdk中是默认开启的,但是jdk认为,在系统刚刚启动的时候,锁的竞争会很激烈,所以对于偏向锁的开启采取了延迟策略,对于一个小的demao来说,可能程序已经运行完了,偏向锁的延迟时间还没到,偏向锁都还没开启。可以设置偏向锁的延迟时间为0.
注意:Hotspot虚拟机在开机启动后有个延迟(4s),经过延迟后才会对每个创建的对象开启偏向锁。我们可以通过设置下面的参数来修改这个延迟,或者直接sleep一段时间
-XX:BiasedLockingStartupDelay=0
示例:
代码:
public static List<Integer> numberList =new Vector<Integer>(); 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); }
|
设置运行参数:
开启偏向锁,并设置延迟时间为0:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
结果:
本例中,使用偏向锁,可以获得5%以上的性能提升
轻量级锁
n BasicObjectLock
– 嵌入在线程栈中的对象
n 如果轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁)
n 在没有锁竞争的前提下,减少传统锁使用OS(操作系统)互斥量产生的性能损耗
n 在竞争激烈时,轻量级锁会多做很多额外操作,导致性能下降
BasicObjectLock由两部分组成,对象头和指向持有这个锁的对象的指针(ptr to obj hold the lock 相关的指针到持有这个锁的对象)。
如果对象没有被锁定:
1,将对象头的Mark指针保存到锁对象(BasicObjectLock)中,BasicObjectLock是嵌入在线程栈中的对象。
2,将对象头设置为指向锁的指针(在线程栈空间中),相当于对象头的指针指向了锁,并且是放在了线程栈里面,而线程栈里面有个指针呢是指向这个对象头的,因此形成了相互之间的一个引用关系。
判断一个线程是否持有轻量级锁,只要判断对象头的指针,是否在线程的栈空间范围内
n 普通的锁处理性能不够理想,轻量级锁是一种快速的锁定方法。
n 如果对象没有被锁定
– 将对象头的Mark指针保存到锁对象(BasicObjectLock)中
– 将对象头设置为指向锁的指针(在线程栈空间中){也就是说,要想操作对象,先会到线程栈中去找到锁对象,而所对象里面保存了对象头的Mark指针,从而可以获取到对象的位置,从而操作对象。而线程栈是线程私有的,所以同一时间只有一个线程能操作这个对象,这样就做到了线程安全}
lock->set_displaced_header(mark); if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) { TEVENT (slow_enter: release stacklock) ; return ; }
|
如果对象没有被锁定,将对象头mark保存到锁对象当中(即BasicObjectLock),并且将对象头设置为指向锁的指针。
先将对象头备份存入BasicObjectLock锁的markOop _displaced_header,然后做一个比较交换,将这个lock(锁本身)的指针放到对象头当中去,形成相互的交换。如果成功了,就表示锁拿到了,因为lock是位于线程栈中的,因此,我们如何判断这个线程持有这个锁,我们只需要判断这个对象头的指针所指向的对象是不是在这个线程的栈的范围当中,如果是,那就证明这个线程持有这把锁。不是,就证明这个线程没有持有这把锁。
lock位于线程栈中
自旋锁
n 当竞争存在时,如果线程可以很快获得锁,那么可以不在OS层挂起线程,让线程做几个空操作(自旋)
n JDK1.6中-XX:+UseSpinning开启
n JDK1.7中,去掉此参数,改为内置实现
n 如果同步块很长,自旋失败,会降低系统性能
n 如果同步块很短,自旋成功,节省线程挂起切换时间,提升系统性能
自旋锁:就是一个线程在自转,自转就是线程在做空循环,什么都不做,但是也不挂起,这个空循环的作用就是在等待一把锁。自旋锁是假定竞争存在的情况,而偏向锁和轻量锁都是假定不存在竞争的情况。自旋锁假定竞争存在,当存在竞争的时候,如果线程可以很快的获得锁,那我们根本就没有必要把它在操作系统层面做挂起,因为在操作系统层面做挂起,做切换,性能损耗是非常严重的。
如果自旋了还是没拿到锁,最终线程还是挂起了,就白白自旋了,浪费了做自旋花费的那些cpu资源。
偏向锁,轻量级锁,自旋锁总结
n 不是Java语言(代码)层面的锁优化方法
n 是内置于JVM中的获取锁的优化方法和获取锁的步骤
– 偏向锁可用会先尝试偏向锁
– 轻量级锁可用会先尝试轻量级锁
– 以上都失败,尝试自旋锁
– 再失败,尝试普通锁,使用OS互斥量在操作系统层挂起
锁是加到对象上的,所以在进入同步方法的一刹那,线程它就必须要获得锁,直到方法结束,这个锁释放。
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到自旋锁,再升级到重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
重量级锁:
这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,在对象头mark中的锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() {
_header =
NULL;
_count =
0;
//记录个数
_waiters =
0,
_recursions =
0;
_object =
NULL;
_owner =
NULL;
_WaitSet =
NULL;
//处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock =
0 ;
_Responsible =
NULL ;
_succ =
NULL ;
_cxq =
NULL ;
FreeNext =
NULL ;
_EntryList =
NULL ;
//处于等待锁block状态的线程,会被加入到该列表
_SpinFreq =
0 ;
_SpinClock =
0 ;
OwnerIsThread =
0 ;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析),ok~,有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。
synchronized代码块底层原理
现在我们重新定义一个synchronized修饰的同步代码块,在代码块中操作共享变量i,如下
public
class SyncCodeBlock {
public
int i;
public
void
syncTask(){
//同步代码块
synchronized (
this){
i++;
}
}
}
编译上述代码并使用javap反编译后得到字节码如下(这里我们省略一部分没有必要的信息):
Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.
class
Last modified
2017-
6-
2; size
426 bytes
MD5 checksum c80bc322c87b312de760942820b4fed5
Compiled from
"SyncCodeBlock.java"
public
class com.zejian.concurrencys.SyncCodeBlock
minor version:
0
major version:
52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
//........省略常量池中数据
//构造函数
public com.zejian.concurrencys.SyncCodeBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=
1, locals=
1, args_size=
1
0: aload_0
1: invokespecial
#1 // Method java/lang/Object."<init>":()V
4:
return
LineNumberTable:
line
7:
0
//===========主要看看syncTask方法实现================
public
void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=
3, locals=
3, args_size=
1
0: aload_0
1: dup
2: astore_1
3: monitorenter
//注意此处,进入同步方法
4: aload_0
5: dup
6: getfield
#2 // Field i:I
9: iconst_1
10: iadd
11: putfield
#2 // Field i:I
14: aload_1
15: monitorexit
//注意此处,退出同步方法
16:
goto
24
19: astore_2
20: aload_1
21: monitorexit
//注意此处,退出同步方法
22: aload_2
23: athrow
24:
return
Exception table:
//省略其他字节码.......
}
SourceFile:
"SyncCodeBlock.java"
我们主要关注字节码中的如下代码
3: monitorenter
//进入同步方法
//..........省略其他
15: monitorexit
//退出同步方法
16:
goto
24
//省略其他.......
21: monitorexit
//退出同步方法
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
synchronized方法底层原理
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:
public
class SyncMethod {
public
int i;
public
synchronized
void
syncTask(){
i++;
}
}
使用javap反编译后的字节码如下:
Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
Last modified
2017-
6-
2; size
308 bytes
MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
Compiled from
"SyncMethod.java"
public
class com.zejian.concurrencys.SyncMethod
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool;
//省略没必要的字节码
//==================syncTask方法======================
public
synchronized
void
syncTask();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
flags: ACC_PUBLIC,
ACC_SYNCHRONIZED
Code:
stack=
3, locals=
1, args_size=
1
0: aload_0
1: dup
2: getfield #
2
// Field i:I
5: iconst_1
6: iadd
7: putfield #
2
// Field i:I
10:
return
LineNumberTable:
line
12:
0
line
13:
10
}
SourceFile:
"SyncMethod.java"
从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态[z3] ,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。
synchronized的可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:
public
class AccountingSync implements Runnable{
static AccountingSync instance=
new AccountingSync();
static
int i=
0;
static
int j=
0;
public
void
run() {
for(
int j=
0;j<
1000000;j++){
//this,当前实例对象锁
synchronized(
this){
i++;
increase();
//synchronized的可重入性
}
}
}
public
synchronized
void
increase()
{
j++;
}
public
static
void
main(String[] args)
throws InterruptedException {
Thread t1=
new Thread(instance);
Thread t2=
new Thread(instance);
t1.start();t2.start();
t1.join();t2.join()
[z4] ;
System.out.println(i);
}
}
正如代码所演示的,在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。
减少锁持有时间
持有时间长,自旋容易失败
减小锁粒度
粒度大,竞争激烈,偏向锁,轻量级锁失败概率就高
n 将大对象,拆成小对象,大大增加并行度,降低锁竞争
n 偏向锁,轻量级锁成功率提高
n 例如ConcurrentHashMap
n HashMap的同步实现
– Collections.synchronizedMap(Map<K,V> m)
– 返回SynchronizedMap对象
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);} }
|
n ConcurrentHashMap
– (hashmap中维护了Entry<K,V>的数组)ConcurrentHashMap将这个数组分成了若干个Segment :Segment<K,V>[] segments
– Segment中维护HashEntry<K,V>
– put操作时
• 先定位到Segment,锁定一个Segment,执行put
n 在减小锁粒度后, ConcurrentHashMap允许若干个线程同时进入
减小锁粒度之ConcurrentHashMap,它将hashMap分为了若干个Segment<K, V>段,我们知道,hashMap内部其实就是一个数组Entry<K,V>[] table,那么,我们拿一个数组实现hashMap,对hashMap对象加锁的时候,就会对那个大数组加锁,每次只有一个线程可以进去操作这个数组,如果我用多个数组去维持一个hashmap,每一次进去,我们只对这个数组的一部分进行加锁,这样就减小了锁的粒度。ConcurrentHashMap会维护若干个Segment,每一个Segment都可以理解成是一个小的hashMap,它里面就会获得hashMap的Entry(表),做同步操作的时候,是先定位到这个Segment,然后锁定这一个Segment,执行put,如果有多个线程要来操作,比如说有两个线程,那么这两个线程分别定位到Segment1和Segment2,那么,这时候它们之间的操作是互不影响的。它们可以同时做这个操作,而不需要进行等待,这个竞争相对来说也就小了很多。
锁分离
对文件读取时,允许多个线程同时进入
n 根据功能进行锁分离
n 例如:ReadWriteLock(读写锁)
n 读多写少的情况,可以提高性能
读写锁,根据操作的性质,将锁分离,读的时候加读锁,写的时候加写锁,如果说已经有一个对象拿到了读锁,其它对象还想拿读锁,这也是可以的。允许同时有多个线程拿到读锁,因此,读的时候可以真正做到多个线程一起并发,一起读,不需要去做等待,这样,系统的性能就提高了。写操作实质上会对对象造成修改,因此,对象可能在写的过程当中,它的状态是不可用的。因此,写的时候是不允许去读的。同时,当一个线程在写的时候,另一个线程也不允许去写。直到写完了,这个对象现在可用了,才行。读写锁可以在读多写少的情况下提高系统的性能。
n 读写分离思想可以延伸,只要两个线程的操作互不影响,锁就可以分离
n LinkedBlockingQueue
– 队列
– 链表
take只作用于队列前端,put只作用于队列尾端
E入队时,只要将D.last=E
A出队时,只要head=head.next
从功能的角度做分离,功能不同,互不影响,就可以分离
LinkedBlockingQueue实现中,可以使用takeLock和putLock两个锁
锁粗化
n 通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化
锁消除
锁不一定是由程序员引入的,JDK自带的一些库,可能内置锁。
栈上对象,不会被全局访问的,没有必要加锁。
n 在即时编译器[z5] 时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作
示例:
代码:
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是局部变量,不可能被线程共享,无需加锁 sb.append(s1);// StringBuffer的append方法是有同步的,加锁了,是线程安全的。 sb.append(s2);// StringBuffer的append方法是有同步的,加锁了,是线程安全的。 return sb.toString(); }
|
运行参数和结果
CIRCLE= 2000000
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
结果:createStringBuffer: 187 ms
CIRCLE= 2000000
-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks
结果:createStringBuffer: 254 ms
参数解释:
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
-server:目前(JDK8u)HotSpot VM里,默认有两个JIT编译器,C1(Client Compiler)和C2(Server Compiler)。-XX:+DoEscapeAnalysis 和 -XX:+EliminateLocks 都是C2特有的功能,只在一个方法被C2编译后才会得到体现。
-XX:+DoEscapeAnalysis:逃逸分析:
1, 它所能做到的是一个path-insensitive(路径不敏感)的分析,看在某个方法内分配的对象,是否只被局部变量所引用,或者只被当作参数传递给一些满足特定条件的方法。
2,注意,这里分析的对象一定是:编译某个方法(编译单元)时,在这个方法内分配的对象(注意,这里分析的对象一定是:编译某个方法(编译单元)时,在这个方法内分配的对象(也就是在该方法及其被内联的方法中直接new出来的对象)。而在编译单元外分配的对象(例如进入该方法之前已经分配出来的对象,或者在未被内联的方法中分配的对象)并不在分析范围之内。而在编译单元外分配的对象(例如进入该方法之前已经分配出来的对象,或者在未被内联的方法中分配的对象)并不在分析范围之内。
3,当C2编译某个方法时,完成逃逸分析后,它就可以进一步做一些优化:
- 标量替换(scalar replacement):对于没有逃逸出当前方法(当前编译单元)的对象,彻底消除对象的内存分配,把它的字段爆开为一个个独立的局部变量;
- 锁消除(lock elision):对于没有逃逸出当前线程的对象,消除对该对象的锁。这里说的锁限定于synchronized意义上的,不包括j.u.c的Lock。
4,再提醒一次,分析归分析,优化归优化。有些优化依赖于某些分析,但分析自身并不做优化…
HotSpot C2所做的标量替换是由 -XX:+EliminateAllocations 参数所控制的,默认打开但要在-XX:+DoEscapeAnalysis也打开的情况下才起作用。
HotSpot C2所做的逃逸分析大部分情况是只针对被编译的方法及所有被内联进来的方法来做分析的。
-XX:+EliminateLocks:这个参数掌控HotSpot C2的lock coarsening(锁粗化)与lock elision(锁消除)两种优化。这两个优化分别对应不同的场景。前者不依赖于逃逸分析,而后者依赖于逃逸分析。
无锁
n 锁是悲观的操作
n 无锁是乐观的操作
n 无锁的一种实现方式
– CAS(Compare And Swap)
– 非阻塞的同步
– CAS(V,E,N)
n 在应用层面判断多线程的干扰,如果有干扰,则通知线程重试
CAS算法的过程是这样:它包含3个参数CAS(V,E,N)。V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS返回当前V的真实值。CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
示例:
n java.util.concurrent.atomic.AtomicInteger
public final int getAndSet(int newValue) {//设置新值,返回旧值 for (;;) {//一直循环,直到return int current = get(); if (compareAndSet(current, newValue))// 更新成功返回true return current; } }
|
public final boolean compareAndSet(int expect, int update)// 更新成功返回true
无锁:跟锁相比,锁是一个悲观的操作,使用锁,是因为我们预期认为,这个时候竞争是存在的,所以,我们要加锁进去。无锁,正好相反,它预期竞争是不存在的,因此相对于锁,无锁就是一种乐观方式,他认为,我这次进去,我一定不会碰到麻烦,因此,我能够把任务执行下来,因此,它就会首先尝试没有问题的这条路,如果,很不幸它发生问题了,他在退而求其次,解决这个问题。锁,是我在问题出现之前,我先去想怎么去解决这个问题,然后再着手做。无锁,是我先不管三七二十一,我先上来做了再说,如果遇到问题,我再回头改。如果没有遇到问题,我就执行成功了,执行结束。无锁是一种非阻塞同步,它不会去等待,它上来就先尝试,如果尝试成功,就成功了。如果尝试不成功则放弃或。。。
无锁的实现方式CAS(V,E,N)
v,表示要更新的这个变量
E,一种期望值,也就是我希望这个v当前是多少
N,就是新的值
它要做的事情是,把新值N赋给变量V,它不是无条件的赋值,它是有条件,当且仅当V等于E的时候,如果V是我期望的那个值E,我就把N这个新的值赋给V。否则,它就什么都不做。做完之后,不管是赋值成功还是失败,它都把执行完之后,V的真实的值返回。如果V的值更新成功了,那么说明V跟预期的期望值E是一样的,如果预期对了,证明当前这个线程并没有受到多线程的干扰。所以就赋值成功了。反则反之。说明当前的线程收到了多线程的干扰了,别的线程修改了这个值,因此,对于我来说,我能做的就是重试,不停的重试,直到我成功。或者呢,我就放弃。CAS(V,E,N)比较交换是一条CPU的指令,所以它可以保证不会再多线程之间发生问题。
[z1]对于synchronized语句块当Java源代码被javac编译成bytecode的时候,会在同步块的入口位置和退出位置分别插入monitorenter和monitorexit字节码指令。当虚拟机遇到monitorenter时,它获得栈中objectref所引用对象的锁,如果线程已经拥有了该对象的锁,锁的计数器就加1,线程中每个monitorexit指令会引起计数器减1.当计数器变为0时,监视就释放了。
[z2]isAlive
public final boolean isAlive()
测试线程是否处于活动状态。如果线程已经启动且尚未终止,则为活动状态。
返回:
如果该线程处于活动状态,则返回 true
;否则返回 false
。
内核态(Kernel Mode):运行操作系统程序
用户态(User Mode):运行用户程序
[z4]join
public final void join()
throws InterruptedException
等待该线程终止。
抛出:
InterruptedException - 如果任何线程中断了当前线程。当抛出该异常时,当前线程的中断状态 被清除。
也就是说,t.join()方法阻塞调用此方法的线程(calling thread),直到线程t完成,此线程再继续;通常用于在main()主线程内,等待其它线程完成再结束main()主线程。
[z5]在部分的商用虚拟机中,Java 程序最初是通过解释器( Interpreter )进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,即时编译器(Just In Time Compiler )会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。
转载于:https://my.oschina.net/kangxi/blog/1823173