前言
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况, 这个资源称之其为临界资源;这种资源可能是:对象、变量、文件等。
- 共享:资源可以由多个线程同时访问
- 可变:资源可以在其生命周期内被修改
由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问
所有的并发模式在解决线程安全问题时,采用的方案都是序列化(有序)访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。
一、synchronized
synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,保证数据原子性
是可重入的。
1、对于静态方法,由于此时对象还未生成,所以只能采用类锁;
2、只要采用类锁,就会拦截所有线程,只能让一个线程访问。
3、对于对象锁(this),如果是同一个实例,就会按顺序访问,但是如果是不同实例,就可以同时访问。
4、如果对象锁跟访问的对象没有关系,那么就会都同时访问。
加锁的方式:
- 同步代码块
public void method(){ /** * 加锁加到当前实例对象上 */ synchronized (object){ int id = 1; System.out.println(id); } }
- 同步实例方法,锁是当前实例对象
- 同步类方法(有static关键字),锁是当前类对象
/** * 加static 加锁加到类对象上 * 锁是当前类对象 * * 不加static 加锁加到this类对象上,当前bean由容器管理,bean必须是单例模式,否则无效 * 锁是当前实例对象 */ public static synchronized void method1(){ int id = 1; System.out.println(id); }
- 不使用synchronized跨方法枷锁
public class DemoSynchronized { private static Object object =new Object(); public static void main(String[] args) throws InstantiationException, IllegalAccessException, NoSuchFieldException { method2(); method3(); } public static void method2() throws InstantiationException, IllegalAccessException { Unsafe unsafe = reflectGetUnsafe(); // 越过jvm虚拟机直接操作底层 unsafe.monitorEnter(object); int id = 1; System.out.println(id); } public static void method3() throws InstantiationException, IllegalAccessException, NoSuchFieldException { Unsafe unsafe = reflectGetUnsafe(); // 越过jvm虚拟机直接操作底层 unsafe.monitorExit(object); int value = 1; } private static Unsafe reflectGetUnsafe() { try { Field field = Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); return (Unsafe) field.get(null); } catch (Exception e) { return null; } } }
每一个对象Object被创建之后,都会在jvm内部维护一个与之对应的Monitor
synchronized是基于JVM内置锁
实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁:操作系统维护)实现,它是一个重量级锁性能较低。
JVM内置锁
在1.5之后版本做了重大的优化
- 锁粗化(Lock Coarsening)
- 锁消除(Lock Elimination)
- 轻量级锁(Lightweight Locking)
- 偏向锁(Biased Locking)
- 适应性自旋(Adaptive Spinning)
- 等技术来减少锁操作的开销
内置锁的并发性能已经基本与Lock持平。
synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
线程Monitor.Exit结束后会通知其他线程获取Monitor.Enter
示例:
public class DemoSynchronized {
private static Object object =new Object();
public static void main(String[] args) {
method();
}
public static void method(){
synchronized (object){
int id = 1;
System.out.println(id);
}
}
}
对象内存结构
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头 (Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等
- 实例数据:即创建对象时,对象中成员变量,方法等
- 对齐填充:对象的大小必须是8字节的整数倍
锁状态是被记录在每个对象的对象头(Mark Word)中
实例对象内存中存储
- 如果实例对象存储在堆区时:实例对象内存存在堆区,实例的引用存在栈上,实例的元数据class存在方法区或者元空间
- Object实例对象不一定是存在堆区:如果实例对象可能产生线程逃逸行为
逃逸分析
/**
* 进行两种测试
* 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
* VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 开启逃逸分析 jdk1.7之后默认开启可以不加 -XX:+DoEscapeAnalysis 这个参数
* VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 执行main方法后
* jps 查看进程
* jmap -histo 进程ID
* 堆:可以理解为java 虚拟机空间
* 栈:线程开辟的空间
*/
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 500000; i++) {
// JIT即时编译器会对编译后的文件进行优化(逃逸优化)
// 并不是所有对象存放在堆区,有的一部分存在线程栈空间
Student student = new Student();
}
long end = System.currentTimeMillis();
//查看执行时间
System.out.println("cost-time " + (end - start) + " ms");
try {
Thread.sleep(100000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
static class Student {
int id;
String name;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
关闭逃逸行为
开启逃逸行为
### 逃逸分析编译器可以对代码做如下优化
- 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
所以不是所有的对象和数组都会在堆内存分配空间
从jdk 1.7开始已经默认开始逃逸分析
public void setObject(){
// 锁不会生效,jvm会进行逃逸分析,进行优化,会产生逃逸,不会进行加锁
// 想当于每次锁都是一个新的对象锁
synchronized (new Object()){
System.out.println(11111);
}
}
锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
锁的升级过程不会失去cpu使用权
Mark Word
偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作耗时)的代价而引 入偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
- 性能能提升10%左右
场景:
- 适合没有锁竞争的场景(单独一个线程访问共享资源 ),偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。
注意:
- 不适合锁竞争比较激烈的场合,偏向锁会失效,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
- 偏向锁不会自动释放,只有产生竞争才会释放
public class Biaslock {
public static Vector<Integer> vector = new Vector<Integer>();
/**
* 默认开启偏向锁
* 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
* 关闭偏向锁:-XX:-UseBiasedLocking
* @param args
*/
public static void main(String[] args){
long begin = System.currentTimeMillis();
int count = 0;
int num = 0;
while(count < 10000000){
vector.add(num);
num = num + 5;
count++;
}
long end = System.currentTimeMillis();
System.out.println(end - begin);
}
}
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。
轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。
线程在进入阻塞块时,jvm不会直接生成重量级锁,因为jvm认为线程的阻塞和唤醒代价太高了,暂时性的自旋,不丢失cpu使用权。
场景:
- 线程竞争不激烈,线程交替执行同步块的场合(线程访问共享资源产生等待的时间相对较短)
注意:
- 如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。 最后没办法也就只能升级为重量级锁了。
java1.7之前可以自定义自旋次数。
java1.7之后自旋,可以根据上一次自旋成功值的次数弹性的决定下一次自旋次数(自适应自旋)
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
JIT:Just In Time Compiler,一般翻译为即时编译器,这是是针对解释型语言而言的,而且并非虚拟机必须,是一种优化手段,Java的商用虚拟机HotSpot就有这种技术手段,Java虚拟机标准对JIT的存在没有作出任何规范,所以这是虚拟机实现的自定义优化技术。
例如:StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量, 并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
/**
* StringBuffer:线程安全的
*/
private static StringBuffer sb = new StringBuffer();
public static void main(String[] args) {
// 同步锁方法,锁加载this实例对象上,理论上3次append相当于加了3次 synchronized锁,每调用一次append相当于加一次锁,需要最少3次上下文切换
// 实际上jvm会对这种情况进行优化,实际上只加了一次 synchronized锁,相当于在所有的append外加了一个总的锁
// 锁的粗化:锁消除
sb.append("1");
sb.append("2");
sb.append("3");
}
锁优化升级过程
Mark Word
无锁到轻量级锁
-
开始只有一个线程,几乎无竞争
到达安全点不意味着线程执行结束,有可能退出同步块,也可能没有退出 -
CAS:在计算机科学中,比较和交换(Conmpare And Swap)是用于实现多线程同步的原子指令。 它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值。 这是作为单个原子操作完成的。 原子性保证新值基于最新信息计算; 如果该值在同一时间被另一个线程更新,则写入将失败。 操作结果必须说明是否进行替换; 这可以通过一个简单的布尔响应(这个变体通常称为比较和设置),或通过返回从内存位置读取的值来完成
无锁到重量级锁
- 起始则由并发访问同一共享资源,存在激烈竞争