1. 同步问题的引出
为什么会产生同步?
多个线程在同一时刻访问共享资源(临界区)带来的问题。
我们先来看一个同步问题的例子:
package www.java.test;
class MyThread implements Runnable{
private int ticket = 10;
@Override
public void run() {
while(this.ticket > 0){
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"还剩下"+this.ticket--+"票");
}
}
}
public class Test{
public static void main(String[] args){
MyThread mt = new MyThread();
Thread th1 = new Thread(mt,"A");
Thread th2 = new Thread(mt,"B");
Thread th3 = new Thread(mt,"C");
th1.start();
th2.start();
th3.start();
}
}
我们从输出结果可以发现,它竟然会出现负数,这是不可能的,在while循环中,当this.ticket=0时都不可能进去的,也就算说0也是不可能出现的,但不仅出现了0,还出现了负数,这是为什么呢?
2.同步处理
2.1 synchronized(内建锁)实现同步处理(加锁操作)
卖票系统出现这样的错误是很严重的,一张票卖了好几次。那如何解决这个问题呢?我们可以给它加一个锁,加锁后就不会出现这种情况了。
package www.java.test;
class MyThread implements Runnable{
private int ticket = 100;
@Override
public void run() {
for(int i = 0; i < 100; i++){
//同步代码块
synchronized (this){
//---------------------------------------------
if(ticket > 0){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"还剩下"+this.ticket--+"票");
}
//---------------------------------------------
}
}
}
}
public class Test{
public static void main(String[] args){
MyThread mt = new MyThread();
Thread th1 = new Thread(mt,"A");
Thread th2 = new Thread(mt,"B");
Thread th3 = new Thread(mt,"C");
th1.start();
th2.start();
th3.start();
}
}
synchronized(this),这个this指的是当前对象。同一时刻只有一个线程在卖票。
3个线程是可以同时进入run()方法的,但是synchronized代码块是同一时刻只有一个线程在执行。
上边那个synchronized锁的是代码块,它也可以锁方法。如下:
package www.java.test;
class MyThread implements Runnable{
private int ticket = 100;
@Override
public void run() {
for(int i = 0; i < 100; i++){
this.sale(ticket);
}
}
//同步方法
public synchronized void sale(int ticket){
if(ticket > 0){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"还剩下"+this.ticket--+"票");
}
}
}
public class Test{
public static void main(String[] args){
MyThread mt = new MyThread();
Thread th1 = new Thread(mt,"A");
Thread th2 = new Thread(mt,"B");
Thread th3 = new Thread(mt,"C");
th1.start();
th2.start();
th3.start();
}
}
这个表示的是一次只能有一个线程进入sale()方法。
总结:
同步代码块:
在方法中使用synchronized(对象),一般可以锁定当前对象this
表示同一时刻只有一个线程能够进入同步代码块,但是多个线程可以同时进入方法。
同步方法:
在方法声明上加synchronized,表示此时只有一个线程能够进入同步方法。
2.2 synchronized对象锁概念
synchronized(this)以及普通的synchronized方法,只能防止多个线程同时执行同一个对象的同步段。synchronized锁的是括号中的对象而非代码。
我们来看看下面这段代码的输出:
package www.java.test;
class Sync{
//锁的是当前对象this
public synchronized void test(){
System.out.println(Thread.currentThread().getName()+"方法开始");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"方法结束");
}
}
class MyThread implements Runnable{
@Override
public void run() {
Sync sync = new Sync();
sync.test();
}
}
public class Test{
public static void main(String[] args){
MyThread mt = new MyThread();
for(int i = 0; i < 3; i++){
new Thread(mt,"线程"+i).start();
}
}
}
我们会发现,虽然加了synchronized,但并没有锁住。
那是因为锁的只是当前对象,这段代码可不是只有一个对象,我们new了3个线程,也就是有3个run方法,每个run方法中都会new一个sync对象,这3个sync对象可是不一样的,线程0对应的是sync1,线程1对应的是sync2,线程2对应的是sync3,这是3个不同的对象,各自锁各自的。
要想把上边那段代码锁住,就需要只创建一个对象,这样才能锁住:
package www.java.test;
class Sync{
public synchronized void test(){
System.out.println(Thread.currentThread().getName()+"方法开始");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"方法结束");
}
}
class MyThread implements Runnable{
private Sync sync;
public MyThread(Sync sync){
this.sync = sync;
}
@Override
public void run() {
this.sync.test();
}
}
public class Test{
public static void main(String[] args){
Sync sync = new Sync();
MyThread mt = new MyThread(sync);
for(int i = 0; i < 3; i++){
new Thread(mt,"线程"+i).start();
}
}
}
虽然还是3个线程,但这3个线程用的是同一个sync对象,因为只new了一次Sync。
2.3 全局锁:锁代码段
上边那段代码如果就想创建3个sync对象,并且还要锁住的话,也可以使用全局锁,锁住它的代码段。这样即使创建了3个不同的sync对象,它也可以锁住,同一时刻只有一个线程在执行此代码段。
package www.java.test;
class Sync{
//此时锁的是下方代码段
public static synchronized void test(){
System.out.println(Thread.currentThread().getName()+"方法开始");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"方法结束");
}
}
class MyThread implements Runnable{
@Override
public void run() {
Sync sync = new Sync();
sync.test();
}
}
public class Test{
public static void main(String[] args){
MyThread mt = new MyThread();
for(int i = 0; i < 3; i++){
new Thread(mt,"线程"+i).start();
}
}
}
这段代码和上边那段没锁住的代码唯一的区别就是在synchronized前加了static关键字。这时相当于锁的是类,静态方法和对象实例化无关,不管底下有多少个对象,锁的是一个类,Sync类只有一个,所以可以锁住。
上边代码的变种:
package www.java.test;
class Sync{
public void test(){
synchronized (Sync.class){
System.out.println(Thread.currentThread().getName()+"方法开始");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"方法结束");
}
}
}
class MyThread implements Runnable{
@Override
public void run() {
Sync sync = new Sync();
sync.test();
}
}
public class Test{
public static void main(String[] args){
MyThread mt = new MyThread();
for(int i = 0; i < 3; i++){
new Thread(mt,"线程"+i).start();
}
}
}
通过现象发现也锁住了。
总结:
- 使用类的静态同步方法
synchronized与static一起使用,此时锁的是当前使用的类而非对象 - 在代码块中锁当前Class对象
synchronized(类名称.class) { }
实际上synchronized锁只有对象锁,全局锁锁的也是对象。
我们可以来证明一下:
package www.java.test;
class Sync{
//线程1
public synchronized void testA(){
if(Thread.currentThread().getName().equals("A")){//为了保证线程A进入test1
while(true){
}
}
}
//线程2
public synchronized void testB(){
if(Thread.currentThread().getName().equals("B")){//为了保证线程B进入test2
System.out.println("testB被调用");
}
}
}
class MyThread implements Runnable{
private Sync sync;
public MyThread(Sync sync) {
this.sync = sync;
}
@Override
public void run() {
sync.testA();
sync.testB();
}
}
public class Test{
public static void main(String[] args) throws InterruptedException {
Sync sync = new Sync();
MyThread mt = new MyThread(sync);
Thread th1 = new Thread(mt,"A");
Thread th2 = new Thread(mt,"B");
th1.start();
Thread.sleep(200);//为了 保证线程A先启动
th2.start();
}
}
上边这段代码,synchronized修饰的是两个普通方法,这两个方法是同步方法,上边的操作其实就是想问你,如果线程A先进入testA()并且一直没出来,问你线程B能否进入testB()。
答案当前是不能,因为synchronized锁的是当前对象,当前对象是sync,只有一个sync对象,testA()进去之后没出来,锁没被释放,testB()也是通过sync对象调用的,这时sync被锁着,所以线程B不能获取到锁,就不能进入testB()方法。
我们可以通过debug模式来观察一下它的运行状态:
我们会发现线程A是运行态,线程B被阻塞着。
2.4 synchronized实现原理
synchronized可使用在代码块和方法中,根据synchronized用的位置可以有这些使用场景:
synchronized在底层到底是如何实现的呢?
对象锁(monitor)机制
我们可以通过一个简单的代码,对它进行反编译看看synchronized的底层实现。
package www.java.test;
public class Test{
public static void main(String[] args){
Object obj = new Object();
synchronized (obj){
System.out.println("hello world");
}
}
}
我们用javap -v Test来看反汇编后生成的部分字节码:
synchronized底层实现
执行同步代码块前首先要执行monitorenter指令,退出时执行monitorexit指令,使用synchronized实现同步,关键点就是要获取对象的监视器monitor对象。当线程获取到monitor对象后,才可以执行同步代码块,否则就只能等待。同一时刻只有一个线程可以获取到该对象的monitor监视器。
通常一个monitorenter指令会同时包含多个monitorexit指令。因为JVM要确保所获取的锁无论是在正常执行路径下或是在异常执行路径下都要能够正确解锁。不能说JVM突然崩了,这个锁就会被一直拿着,如果程序报错,也要把这个锁释放了,程序才能报错。
我们再来看看synchronized修饰同步方法,它的底层又是如何实现的呢?
package www.java.test;
public class Test{
public static void main(String[] args){
test();
}
public static synchronized void test(){//加了static,锁的是当前的Test类
System.out.println("hello world");
}
}
反汇编的部分字节码:
同步方法底层实现:
当使用synchronized标记方法时,字节码会出现访问标记ACC_SYNCHRONIZED。该标记表示在进入该方法时,JVM需要进行monitorenter操作。在退出该方法时,无论是否正常返回,JVM均需要monitorexit操作。
当JVM执行monitorenter时,如果目标对象monitor的计数器为0,表示此时该对象没有被其他线程所持有。此时JVM会将该锁对象的持有线程设置为当前线程,并将monitor计数器进行+1操作。
在目标锁对象的计数器不为0的情况下,如果锁对象的持有线程是当前线程,JVM可以将计数器再次+1(可重入锁);否则需要等待,直到持有线程释放该锁。
可重入锁
例:
package www.java.test;
public class Test{
public static void main(String[] args){
test1();
test2();
}
public static synchronized void test1() {//加了static,锁的是当前的Test类
System.out.println("hello world");
}
public static synchronized void test2() {//加了static,锁的是当前的Test类
System.out.println("hello world");
}
}
}
两个锁的都是Test类。
当前线程(主线程)在进入test1()之后,还能再进入test2(),这就是可重入锁。
主线程在进入test1()之前,monitor计数器为0,直接进入,monitor计数器+1,表示当前线程(主线程)持有该锁,在调用test2()的时候,当前线程(也是主线程)调用,因为主线程已经持有该锁了,所以monitor+1,现在计数器为2了。如果当前锁的持有对象不是当前线程,则必须等待,直到monitor计数器等于0时,才可以再次去竞争该锁。
当执行monitorexit指令时,JVM需要将锁对象计数器-1。当计数器减为0时,代表该锁已被释放掉,唤醒所有正在等待的线程去竞争该锁。
我们可以验证一下可重入锁的可重入性:
package www.java.test;
class Sync{
//线程1
public synchronized void testA(){
if(Thread.currentThread().getName().equals("A")){//为了保证线程A进入test1
while(true){
testB();
}
}
}
//线程2
public synchronized void testB(){
System.out.println("testB被调用");
}
}
class MyThread implements Runnable{
private Sync sync;
public MyThread(Sync sync) {
this.sync = sync;
}
@Override
public void run() {
sync.testA();
sync.testB();
}
}
public class Test{
public static void main(String[] args) throws InterruptedException {
Sync sync = new Sync();
MyThread mt = new MyThread(sync);
Thread th1 = new Thread(mt,"A");
Thread th2 = new Thread(mt,"B");
th1.start();
Thread.sleep(200);//为了 保证线程A先启动
th2.start();
}
}
这段代码只是对前边那个证明synchronized是对象锁的代码做了一个小改动。
我们可以观察一下输出结果,发现testB()函数又可以被调用了。
难道是上一次运行时是编译器出错了???
这是不可能的,出现这种现象就是因为可重入锁的可重入性。
因为是A线程调用了testA(),在进入testA()的时候,它拿到了锁,所以当前持有锁的线程就是A线程,然后在testA()中调用testB(),这时monitor的计数器不为1,所以要判断当前线程是不是当前持有锁线程,只有是,才可重入,因为当前持有锁线程是A线程,而调用testB()的也是A线程,所以锁可重入,testB()可以被调用。
其实这个可重入锁通俗的来讲就是,当你回家的时候需要开锁,一旦把这个锁打开,你进去之后,就可以在家里为所欲为,可以做饭的同时看电视,不需要看电视开一次家门,做饭开一次家门。
上边这些都是对象锁(monitor)机制,是JDK1.6之前synchronized底层原理,对应JDK1.6以后的重量级锁。线程的阻塞以及唤醒,均需要操作系统由用户态切换到内核态,开销非常大,因此这种线程实现的同步效率很低。
所以在JDK1.5引入了一个Lock体系,这个会在我的另一篇博客中做详细介绍。
3.内建锁的优化
在JDK1.6之后,JVM对内建锁进行了很大的优化。
3.1 CAS(Compare and Swap)
悲观锁:线程获取锁(JDK1.6之前的内建锁)是一种悲观锁的策略。假设每一次执行临界区代码(访问共享资源)都会产生冲突,所以当前线程获取到锁的同时也会阻塞其他未获取到锁的线程。
乐观锁(CAS操作,无锁操作):假设所有线程访问共享资源时不会出现冲突,由于不会出现冲突自然就不会阻塞其他线程。因此线程就不会出现阻塞停顿的状态。出现冲突时,无锁操作使用CAS(比较交换)来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
CAS通俗的来讲就是不断的尝试,比如你去商场买口红,想要的色号就只剩下那一支了,另两个人也想买那个色号,但是你去的早,你一直拿着那支口红没放下,可是是在月末,你的囊中羞涩,是支付不了那支口红的价格的,所以你一直很犹豫,旁边的人看出了你的窘境,所以她一直在那等着,看看其他化妆品,时不时地看你走了没,如果你走了,她就立马过去买下那支口红,这就是CAS重试。
而另外一个人想着你反正也不买,她还想去逛逛,所以就走了,打算逛一会再过来买,可是当她过来时发现那支口红已经被卖掉了,而买主是那个一直等在旁边的那个人,这是阻塞。
阻塞和唤醒是由用户态切换到内核态,这种开销是很大的,而CAS操作是一直在跑,不会停止,只不过它跑在CPU上的是无用指令,所以自旋要比阻塞快百倍。
3.2 CAS操作过程:
CAS可以理解为CAS(V,O,N):
V:当前内存地址实际存放的值
O:预期值(旧值)
N:更新的新值
当V = O时,期望值与内存实际值相等,表示该值没有被其他任何线程修改过,即值O就是目前最新的值,因此可以将新值N赋给V。
如果V != O,表明该值已经被其他线程修改过了,因此O值并不是当前最新值,返回V,无法修改。
当多个线程使用CAS操作时,只有一个线程会成功,其余线程均失败。失败的线程会重新尝试(自旋)或挂起线程(阻塞)。
3.3内建锁和自旋的区别
内建锁在老版本最大的问题在于:在存在线程竞争的情况下会出现线程的阻塞以及唤醒带来的性能问题,这是一种互斥同步(阻塞同步)。
而CAS不是将线程挂起,当CAS失败后会进行一定的尝试并非耗时的将线程挂起,也叫非阻塞同步。
3.4 CAS(自旋)的问题
3.4.1 ABA问题
假设一个进程先进来了,线程1先把V改为A,将N改为1
线程2知道当前V=A,所以它的期望值O就变为了A,在它刚进行了这个操作后,还没来得及改变V值,将N改为4
线程3又将V变为了B(因为线程3进来时,V=O=A),线程2会进行重试(因为O=A,V=B),将N改为2
线程4进来了,在线程2重试前,线程4比线程2快先进来了,又把V变回了A,在线程2看来,它的V和O并没有变化,是可以改的,将N改为3
如果线程2能改,线程4就废掉了,本来最新修改值的是线程4,如果被线程2修改,则线程4修改的值就全部废掉了
按照逻辑来讲,线程2是不可以修改值的,得先从内存获取到最新的值N=3,才能改,在N=3的基础上改,不然4就废了
解决方法:使用atomic包提供的AtomicStampedReference类来解决
1A -> 2B -> 3A,虽然它们的值一样,但版本号不同,所以无法修改
此时1A和3A不是同一个东西,添加了一个叫版本号的东西
3.4.2 自旋会浪费大量的CPU资源
与线程阻塞相比,自旋会浪费大量的处理器资源。因为当前线程仍处于运行状态,只不过跑的是无用指令。
解决:自适应自旋:根据以往自旋等待时能否获取锁,来动态调整自旋的时间(循环次数)。如果在自旋时获取到锁,则会稍微增加下一次自旋的时长;否则就稍微减少下一次自旋时长。
例:假设你开车时遇到了红灯,有一种方法是你可以熄火,还有一种是你可以脚一直踩在刹车上。
熄火就相当于阻塞;自旋就相当于你脚在刹车上。
如果在遇到红绿灯的时候,还没等就已经变绿灯了,那下一次不熄火的时间就长点,如果之前等了好久才等到绿灯,那下次不熄火等待的时间就短点。
3.4.3 公平性(让等待时间最长的线程获取到锁)
内建锁无法实现公平机制,而Lock体系可以实现公平锁。
3.5 Java对象头
Java对象头Mark Word字段存放内容:
对象的Hashcode
分代年龄
锁标记位
Mark Word的存储结构:
对象的Mark Word变化:
JDK1.6之后锁一共有四种状态:
无锁 0 01
偏向锁 1 01
轻量级锁 00
重量级锁 10
根据竞争状态的激烈程度,锁会自动进行升级,锁不能降级(为了提高锁获取与释放的效率)。
3.6 偏向锁(一个线程一个锁)
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。为了让线程获取锁的开销降低引入偏向锁。
偏向锁是锁状态中最乐观的一种锁:从始至终只有一个线程请求一把锁。
偏向锁的获取:
当一个线程访问同步块并成功获取到锁时,会在对象头和栈帧中的锁记录字段存储锁偏向的线程ID,以后该进程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,直接进入。
当线程访问同步块失败时(也就是偏向锁被另一个线程拿着),使用CAS竞争锁,并将偏向锁升级为轻量级锁。
偏向锁的撤销:开销较大
偏向锁使用了一种等待竞争出现才释放锁的机制,所以当其他线程竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,并将锁膨胀为轻量级锁(持有偏向锁的线程依然存活的时候,也就是说在线程1(持有线程)还没执行完的时候,线程2竞争到了偏向锁)。
如果持有线程线程已经终止,则将锁对象的对象头设置为无锁状态。这个时候竞争的线程就有机会获取到这个偏向锁,就把这个偏向锁指向了自己,还是偏向锁,因为不存在竞争(无锁–>偏向锁)。
偏向锁的头部有一个Epoch字段值:表示此对象偏向锁的撤销次数,默认撤销40次以上,表示此对象不再适用于偏向锁,当下次线程再次获取此对象时,直接变为轻量级锁。
只有一次CAS过程,出现在第一次加锁时。
JDK6之后偏向锁默认开启。
3.7 轻量级锁(多个线程在不同时刻一个锁)
多个线程在不同的时间段请求同一把锁,也就是不存在锁竞争的情况。针对这种情况,JVM采用了轻量级锁来避免线程的阻塞与唤醒。
只要有线程竞争就会膨胀为重量级锁。
3.8 重量级锁(多个线程在同一时刻一个锁)
三种锁特点:
- 偏向锁只会在第一次请求锁时采用CAS操作并将锁对象的标记字段记录下当前线程地址。在此后的运行过程中,持有偏向锁的线程不需要加锁操作。
针对的是锁仅会被同一线程持有的情况。 - 轻量级锁采用CAS操作(每次都会),将锁对象标记字段替换为一个指针,指向当前线程上的一块空间,存储着锁对象原本的标记字段。
针对的是多个线程在不同时间段申请同一把锁的情况。 - 重量级锁会阻塞、唤醒请求加锁的线程。针对的是多个线程同时竞争同一把锁的情况。JVM采用自适应自旋来避免面对非常小的同步代码块时,仍会被阻塞和唤醒的状况。
3.9 其他优化
锁粗化:就是将多次连接在一起的加锁、解锁操作合并为一次操作。将多个联系的锁扩展为一个范围更大的锁。
例:
public class Test{
private static StringBuffer sb = new StringBuffer();
public static void main(String[] args) {
sb.append(1);
sb.append(2);
sb.append(3);
sb.append(4);
}
}
append()这个方法是被synchronized所修饰的(加锁,因为有线程安全问题(因为这个sb是全局变量多个线程共用一个sb)),难道我们调用四次append,就需要四次加锁解锁操作吗?这样太复杂了,所以把它优化,只有在第一次调用append()的时候进行加锁操作,在最后一次调用append()操作的时候才解锁,这就是锁粗化。
锁消除:删除不必要的加锁操作。根据代码逃逸技术,如果判断一段代码中,堆上的数据不会逃逸出当前线程,则认为此代码是线程安全的,不需要加锁。
例:
public class Test{
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
sb.append(1);
sb.append(2);
sb.append(3);
sb.append(4);
}
}
因为这个sb是局部变量,每个线程都有自己的sb,不存在竞争问题,所以不需要加锁,JVM会把这个锁删除掉。