并发编程 — 3. synchronized关键字

synchronized的出现打破了volatile关键字的局限性(无法保证原子性和只能修饰单一变量),它可以用来锁住代码块、实例对象、类对象。

synchronized的使用

a. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁

//Thread类
public class MyThread implements Runnable{
    private static int count;

    public MyThread(){
        count = 0;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "准备开始执行!");
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + ": " + (count++));
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}


//Main函数
public class Main {
    public static void main(String[] args){
        MyThread myThread = new MyThread();
        Thread t1 = new Thread(myThread, "thread1");
        Thread t2 = new Thread(myThread, "thread2");
        t1.start();
        t2.start();
    }
}

执行结果:

thread1准备开始执行!
thread1: 0
thread2准备开始执行!
thread1: 1
thread1: 2
thread1: 3
thread1: 4
thread2: 5
thread2: 6
thread2: 7
thread2: 8
thread2: 9

从结果可以看出,在run方法中,thread2的自增输出在thread1的自增输出后,而没有被修饰到的print语句则可以先执行。说明在synchronized修饰代码块时获得这个对象的锁且只锁住这段代码块,不影响其他未被修饰部分的执行。

b.修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

将synchronized用来修饰run方法。

public synchronized void run() {
    System.out.println(Thread.currentThread().getName() + "准备开始执行!");
    for (int i = 0; i < 5; i++) {
        System.out.println(Thread.currentThread().getName() + ": " + (count++));
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

再执行,得到的结果如下:

thread1准备开始执行!
thread1: 0
thread1: 1
thread1: 2
thread1: 3
thread1: 4
thread2准备开始执行!
thread2: 5
thread2: 6
thread2: 7
thread2: 8
thread2: 9

从结果可以看出,方法被thread1抢占后,整个方法被锁住,thread2要等待thread1执行退出后才能执行。

但如果将传入两个不同实体,让其在两个线程跑,即修改main函数为:

MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
Thread t1 = new Thread(thread1, "thread1");
Thread t2 = new Thread(thread2, "thread2");
t1.start();
t2.start();

再执行,得到结果:

thread1准备开始执行!
thread1: 0
thread2准备开始执行!
thread2: 1
thread1: 2
thread2: 3
thread1: 4
thread2: 5
thread2: 6
thread1: 6
thread1: 7
thread2: 7

由此可见,synchronized在修饰实例方法是,只是对实例对象进行了加锁,不影响到其他实例对象锁的获取。

c. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 

再接着将MyThread类的count自增封装为一个静态函数,并用synchronized修饰。即改为:

private static synchronized void count(){
    System.out.println(Thread.currentThread().getName() + "准备开始执行!");
    for (int i = 0; i < 5; i++) {
        System.out.println(Thread.currentThread().getName() + ": " + (count++));
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
@Override
public void run() {
    count();
}

再运行得到结果:

thread1准备开始执行!
thread1: 0
thread1: 1
thread1: 2
thread1: 3
thread1: 4
thread2准备开始执行!
thread2: 5
thread2: 6
thread2: 7
thread2: 8
thread2: 9

可以看出,synchronized修饰静态方法时,类的对象实例调用函数时是对类进行加锁的,所以类的其他对象实例被挂起等待。

d. synchronized和volatile的区别

(1)volatile只能作用于变量,使用范围较小。synchronized可以用在方法、类、同步代码块等,使用范围比较广。 (要说明的是,java里不能直接使用synchronized声明一个变量,而是使用synchronized去修饰一个代码块或一个方法或类。)
(2)volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、原子性synchronized都可以保证。 
(3)volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。

 

   synchronized修饰的部分被一个线程上锁(重量锁)的后,其他线程如果来访问这部分代码就会被阻塞,直到解锁后才会被唤醒执行。而java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。为了减少获得锁和释放锁所带来的性能消耗,提高性能,synchronized在JDK 1.6以后做了优化,引入了新的锁机制。

四种锁态

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。

a.存储方式

锁的状态保存在对象的头文件中,以32位的JDK为例:

          位数

锁状态

                                    25bit4bit1bit2bit
23bit2bit是否偏向锁锁标志位
无锁对象的hashcode分代年龄001
偏向锁线程ID偏向时间戳分代年龄101
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量的指针10
GC标记11

b.偏向锁

偏向锁目的是为了减少数据在无竞争情况下的性能消耗。其核心思想就是锁会偏向第一个获取它的线程,在接下来的执行过程中该锁没有其他的线程获取,则持有偏向锁的线程永远不需要再进行同步。

  1. 获取:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里储存锁偏向的线程ID。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要检查当前Mark Word中储存的线程是否指向当前线程,如果成功,表示已经获得对象锁;如果检测失败,则需要再测试一下Mark Word中偏向锁的标志是否已经被置为1(表示当前锁是偏向锁):如果没有则使用CAS操作竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
  2. 释放:偏向锁使用一种等待竞争出现才释放锁的机制,所以当有其他线程尝试获得锁时,才会释放锁。偏向锁的撤销,需要等到安全点。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态;如果依然活动,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁或者标记对象不合适作为偏向锁(膨胀为轻量级锁),最后唤醒暂停的线程。

c.轻量锁

轻量锁本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

  1. 获取:线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于储存锁记录的空间(LockRecord),并将对象头的Mark Word信息复制到锁记录中。然后线程尝试使用CAS将对象头的MarkWord替换为指向锁记录的指针。如果成功,当前线程获得锁,并且对象的锁标志位转变为“00”,如果失败,表示其他线程竞争锁,当前线程便会尝试自旋获取锁。如果有两条以上的线程竞争同一个锁,那么轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态变为“10”,MarkWord中储存的就是指向重量级锁(互斥量)的指针,后面等待的线程也要进入阻塞状态。
  2. 释放:轻量级锁解锁时,同样通过CAS操作将对象头换回来。如果成功,则表示没有竞争发生。如果失败,说明有其他线程尝试过获取该锁,锁同样会膨胀为重量级锁。在释放锁的同时,唤醒被挂起的线程。

d.状态转换图

锁对比

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用于只有一个线程访问同步块场景。

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度。

如果始终得不到锁竞争的线程使用自旋会消耗CPU。

追求响应时间。

同步块执行速度非常快。

重量级锁

线程竞争不使用自旋,不会消耗CPU。

线程阻塞,响应时间缓慢。

追求吞吐量。

同步块执行速度较长。

其他优化

  1. 适应性自旋:当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
  2. 锁粗化:锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。
  3. 锁消除:锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值