并发编程的三大问题
首先我们要知道为什么要使用synchronized加锁,那是因为在并发编程中会出现原子性问题、可见性问题、有序性问题,导致结果不是我们希望的,所以需要进行同步操作,而使用synchronized加锁是一种保证同步性的方法。下面我们来讲解一下并发编程的这三大问题。
原子性问题
原子性问题指的是在一次或者多次操作中,要么所有操作都执行,要么所有操作都不执行。通过下面的例子你可以发现原子性问题。
public class Demo {
static int count;//记录用户访问次数
public static void request() throws InterruptedException {
//模拟请求耗时5毫秒
TimeUnit.MILLISECONDS.sleep(5);
count++;
}
public static void main(String[] args) throws InterruptedException {
//开始时间
long startTime = System.currentTimeMillis();
int threadSize = 100;
//CountDownLatch类就是要保证完成100个用户请求之后再执行后续的代码
CountDownLatch countDownLatch = new CountDownLatch(threadSize);
for (int i = 0; i < threadSize; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
//模拟用户行为,访问10次网站
try{
for (int j = 0; j < 10; j++)
request();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
}
});
thread.start();
}
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println(Thread.currentThread().getName()+"耗时:"+(endTime-startTime)+",count="+count);
}
}
同时又100个线程,每个线程执行了10次request()方法,对count变量自增,结果却不是1000,这是因为count++并不是一个原子性操作,通过反编译可以知道count++包含了四条字节码指令,所以多个线程同时操作的时候,线程执行count++会收到其他线程的干扰。
可见性问题
可见性问题指的是一个线程在访问一个共享变量的时候,其他线程对该共享变量的修改对于第一个线程来说是不可见的,下面通过一个例子可以发现可见性问题。
public class Visable {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
while(flag) {
}
}).start();
Thread.sleep(2000);
new Thread(() -> {
flag = false;
System.out.println("修改了共享变量flag的值");
}).start();
}
}
在这份代码中,声明了一个共享变量flag,然后声明了一个线程一直在读这个共享变量,另一个线程修改了共享变量,我们运行发现,当另一个线程修改了共享变量之后,第一个线程仍然在循环运行,所以这就是并发编程中的可见性问题。
有序性问题
有序性问题指的就是JVM在编译器和运行期会对执行进行一个重排序,导致最终程序代码运行的顺序与开发者一开始编写的顺序不一致,导致出现有序性问题。
Java内存模型
Java内存模型也称为JMM,与Java内存结构不是一个概念,JMM其实是一套规范,描述了Java中各种变量的访问规则,以及在JVM中将变量存储到内存和从内存中读取数据的底层实现细节。我们来看一下下面这个图。
从上图可以看到,共享变量都是存放在主内存中的,然后每个线程都会有自己的工作内存,要对共享变量进行操作,就要先从主内存中拷贝一份共享变量的副本,然后操作完了,再同步到主内存中去,这就是Java内存模型。
工作内存与主内存数据交互的过程
工作内存与主内存之间进行数据交互,主要涉及八大原子操作。
read——从主内存中读取共享变量flag。
load——将读取的共享变量flag写入线程1的工作内存中。
use——从工作内存中读取数据进行操作。
assign——将计算后的结果写入到工作内存中去。
store——从工作内存中取出变量写入主内存中。
write——将store写入的变量值赋值给主内存中对应的变量。
lock——将主内存共享变量加锁,标志为线程独占状态。
unlock——释放主内存共享变量的锁。
synchronized的使用
修饰方法
静态方法
public class Demo{
public static synchronized void request(){
.....
}
}
synchronized修饰静态方法的时候其实是锁定了Demo的类对象。
非静态方法
public class Demo{
public synchronized void request(){
.....
}
}
synchronized修饰非静态方法的时候其实是锁定类的this对象。
修饰代码块
public class Demo{
private Object o = new Object();
public static void request(){
synchronized(o) {
...
}
}
public static void request1(){
synchronized(this) {
...
}
}
}
synchronized(o)锁定的其实是o对象,而synchronized(this)锁定的其实是this对象。
synchronized的特性
synchronized主要有两大特性,分别是不可中断特性和可重入特性。
不可中断特性
不可中断特性指的是,当线程在竞争共享资源的时候,如果资源已经上锁了,那么线程会阻塞等待,直到锁释放,这个阻塞等待过程是不能被中断的。通过下面的例子可以证明synchronized的不可中断特性。
public class UnBlocked {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
synchronized (this) {
try {
TimeUnit.MILLISECONDS.sleep(8888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t1 = new Thread(runnable);
Thread t2 = new Thread(runnable);
t1.start();
Thread.sleep(1000);
t2.start();
System.out.println("t2中断前");
t2.interrupt();
System.out.println("t2中断后");
System.out.println("t1线程的状态:"+t1.getState());
System.out.println("t2线程的状态:"+t2.getState());
}
}
这段代码中声明了两个线程,线程t1一直占用着锁对象,然后线程t2一直处于阻塞状态,调用中断函数也不可中断线程t2的阻塞状态,所以这就是synchronized的不可中断性。
Q:synchronized与Lock的区别
提到synchronized的不可中断特性,不得不提到Lock,对于Lock来说默认是不可中断的。但是可以调用Lock对象的trylock()方法,可以在线程阻塞等待一段时间之后,自动中断阻塞等待状态,这也是synchronized与Lock的一个区别。其次还有Lock可以返回锁的状态,而synchronized是一个无状态锁,你是不知道线程的锁定状态的。
可重入性
synchronized的可重入性指的是同一个线程可以多次获取同一把锁,synchronized的锁对象中会有一个计数器,当同一个线程访问该锁对象,计数器会加一,然后释放锁的时候,计数器会减一。
synchronized的底层原理
通过反汇编的方式了解synchronized的底层原理
首先我们反编译用synchronized修饰的代码块,看看被synchronized修饰的代码块的字节码指令有什么不同?
public class Demo1 {
private static Object object = new Object();
private static int count = 0;
public static void main(String[] args) {
synchronized (object) {
count++;
}
}
}
我们发现在count++的四个字节码指令外面包裹了一个monitorenter和monitorexit字节码指令。
monitorenter指令
每个对象都会对应一个monitor对象,而这个monitor对象才是synchronized需要锁定的对象,这个monitor对象被占用时,其他线程是无法获取monitor对象的。这个monitor对象主要有两个属性,一个是记录线程的重入次数_recursion,和线程的拥有者owner
当JVM执行到某个线程中某个方法的monitorenter字节码指令的时候,会尝试去获取当前对象的monitor对象的所有权,具体过程如下:
若monitor的可重入数为0,则该线程可以获得monitor的所有权,将monitor的可重入数置为1,然后当前线程成为monitor的所有者(owner)。
若该线程已经拥有了monitor的所有权,可以直接重入,然后monitor的可重入数增加1。
若monitor对象已经被其他线程拥有了,当前尝试获取monitor对象的线程会被阻塞,直到monitor的进入数为0时,才能重新尝试获取monitor对象。
monitorexit指令
monitorexit指令的执行过程如下:
首先执行monitorexit指令的线程一定是拥有了monitor对象的所有权的。
执行monitorexit指令会将monitor对象的可重入数减一,如果可重入数为0,当前线程就会释放monitor对象,不再拥有monitor对象的所有权。此时其他阻塞等待获取这个monitor对象的线程就会被唤醒,重新尝试获取monitor对象。
Q:为什么会有两个monitorexit指令?
这是因为当执行同步块代码过程中发生异常了,会自动释放锁,所以第二个monitorexit指令是用于异常释放锁的。
Q:为什么说在JDK1.6之前synchronized是重量级锁?
JDK1.6访问synchronized修饰的代码块或者方法,都会加锁,首先加锁的过程就是获取monitor对象的过程,涉及到一些系统调用,所以会有内核态和用户态切换,就会消耗资源。其次,线程的阻塞和唤醒过程涉及到了线程上下文切换,也是涉及了内核态和用户态的切换,所以整体来说JDK1.6之前synchronized是一个重量级锁。
synchronized的锁升级过程
synchronized在JDK1.6之后进行了一个优化,根据并发量的不同,经历了无锁->偏向锁->轻量级锁->自旋锁->重量级锁这么一个锁升级的过程。
MarkWord
在前面讲解Java对象内存布局中提到过MarkWord,与Java中各种锁息息相关,因为里面就保存了关于锁的信息。
偏向锁
偏向锁,顾名思义就是偏向第一个获取到锁对象的线程,并且在运行过程中,只有一个线程会访问同步代码块,不会存在多线程竞争,这种情况下加的就是偏向锁。
偏向锁的获取过程
判断对象的MarkWord。
判断MarkWord中是否开启偏向锁模式。
如果为可偏向状态,判断MarkWord中的ThreadID是否为空,如果为空,则通过CAS操作设置成当前线程的线程ID,然后执行步骤5。否则执行步骤4。
如果不为空,则判断是否是当前线程ID,如果是则直接执行同步代码块,如果不是,则存在锁竞争,等到全局安全点的时候撤销偏向锁,将锁标记设置为无锁或者轻量级锁状态。
执行同步代码块。
偏向锁撤销
偏向锁的撤销必须等到全局安全点,指的是没有字节码执行执行的时刻。
暂停持有偏向锁的线程,判断锁对象是否处于被锁状态。
撤销偏向锁,恢复到无锁或者升级到轻量级锁。
偏向锁撤销的过程中,在全局安全点的时候,会STW(STOP THE WORLD),如果确定应用程序中存在锁竞争,可以通过设置参数-XX:-UseBiasedLocking=false来关闭偏向锁模式,减少额外的开销。
轻量级锁
在偏向锁状态下,当有另一个线程尝试进入同步代码块的时候,就会存在锁竞争,此时偏向锁就会升级为轻量级锁。
轻量级锁的获取过程
获取MarkWord对象。如果同步对象锁的状态为无锁状态,首先在当前线程的栈帧中创建一个锁记录(Lock Record)。这时候栈帧和MarkWord的状态如图:
拷贝对象头的MarkWord复制到锁记录中的displaced hdr字段。
拷贝成功之后通过CAS操作让MarkWord更新为指向Lock Record的指针,并将Lock Record中的owner指针指向MarkWord对象。如果更新成功则执行步骤4,否则执行步骤5。
如果更新成功,则线程拥有了锁对象,执行同步代码块
如果更新不成功,判断MarkWord中是否指向当前栈帧中的Lock Record,如果是则直接执行同步代码块,如果不是,则存在锁同时竞争,轻量级锁就要升级为重量级锁。
自旋锁
如果存在多个线程同时竞争轻量级锁的时候,会膨胀升级为重量级锁,但是对于当前线程来说,会尝试自旋来获取锁,而不会立刻就阻塞等待,自旋锁其实就是采用循环去获取锁的一个过程。
可以通过参数-XX:+UseSpinning来开启自旋锁。如果一直自旋的话,就会消耗完CPU资源,所以一般是自旋超过一定次数就会退出自旋模式,而进入重量级锁。可以通过设置参数-XX:PreBlockSpin来更改自旋默认次数。
自适应自旋
在JDK1.6中对自旋锁进行了优化,如果一个在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么就会延长自旋的次数。如果对于某个锁,自旋很少成功获得过锁,则会直接忽略掉自旋过程,避免浪费CPU资源。
重量级锁
重量级锁指的就是获取monitor对象的过程,需要调用操作系统的底层函数,所以涉及到内核态和用户态的切换,切换成本非常高。
使用场景
偏向锁:适用于没有线程竞争资源的场景。
轻量级锁:适用于多个线程不同时刻竞争资源的场景。
重量级锁:适用于多个线程同时竞争资源的场景。
小结
所以synchronized的锁升级过程为
检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
如果自旋成功则依然处于轻量级状态。
如果自旋失败,则升级为重量级锁。
锁优化
上面介绍了synchronized在JDK1.6之后的优化过程,进行了一个锁升级过程。那么我们在写多线程代码的时候也可以借鉴synchronized锁优化中的一些思想来优化我们的代码
减少锁的时间
不需要同步执行的代码,不要放到同步代码块中,可以让锁尽快释放。
减少锁的粒度
减少锁的粒度的思想就是将一把锁拆分成多把锁,增加了并发度,减少了锁竞争,ConcurrentHashMap在JDK1.8之前就是采用了”分段锁“的思想,还有LongAddr也是采用了”分段锁“的思想,降低了锁的粒度,提高了并发度。
锁粗化
如果同步代码块中包含循环,应该将锁放在循环以外,不然循环每次都会进入共享资源,效率会降低。
锁消除
锁消除其实就是对于同步代码块执行时间不长,并且并发量不大的情况下,可以采用CAS等乐观锁来进行同步操作,减少了加锁释放锁带来的开销。
读写分离
读写分离指的是,写的时候可以复制一份数据,然后写操作可以加锁,读操作就读原来的数据,比如CopyOnWriteArrayList集合类采用的就是读写分离的方式来解决并发安全的问题。
网友评论