并发编程 -- volatile、synchronized和lock

1. 并发编程中的三个概念

  并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:

  1. 原子性
      即一个操作或者多个操作要么全部执行并且不会被任何因素打断,要么就都不执行。
  2. 可见性
      指多个线程访问同一变量时,一个线程修改了这个变量的值,其他线程能立即看到修改的值。
  3. 有序性
      即程序执行的顺序按照代码的先后顺序执行。
      一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,这叫做指令重排序(Instruction Reorder)。
      处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
      指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
      也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确

2. Java内存模型

在这里插入图片描述
  (1)每个线程都有自己的本地内存空间(java栈中的帧)。线程执行时,先把变量从内存读到线程自己的本地内存空间,然后对变量进行操作。
  (2)对该变量操作完成后,在某个时间再把变量刷新回主内存。

  那么我们再了解下锁提供的两种特性:互斥(mutual exclusion) 和可见性(visibility):

  (1)互斥(mutual exclusion):互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据;

  (2)可见性(visibility):简单来说就是一个线程修改了变量,其他线程可以立即知道。保证可见性的方法:volatile,synchronized,final(一旦初始化完成其他线程就可见)。

3. volatile

  一旦一个共享变量(类成员变量、类静态成员变量)被volatile修饰之后,那么就具备了如下特点:
  1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2)禁止进行指令重排序。
  3)不保证原子性

3.1 可见性

  先看一段代码,假如线程1先执行,线程2后执行:

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}
 
//线程2
stop = true;

  这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
  下面解释一下这段代码为何有可能导致无法中断线程。每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
  那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
  但是用volatile修饰之后就变得不一样了:
  第一:使用volatile关键字会强制将修改的值立即写入主存;
  第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
  第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
  那么线程1读取到的就是最新的正确的值。

3.2 非原子性

  看一个例子:

public class VolatileTest {
	public volatile int inc = 0;
	public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        for(int i=0;i<10;i++){
            new Thread(() -> {
                for (int j = 0; j < 1000; j++)
                    test.increase();
            }).start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

  这个程序的结果不是10000,而是每次运行结果都不一致,都是一个小于10000的数字。
  自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
  假如某个时刻变量inc的值为10,
  线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;
  然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
  然后线程1接着进行加1操作,由于已经读取了inc的值(即使缓存失效了,但是已经读取了),注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。
  那么两个线程分别进行了一次自增操作后,inc只增加了1。
  解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。线程2修改后会写入主存,主线程通知其他线程缓存失效,但是此时线程1已经进行了读取操作了,所以线程1拿着老的值+1覆盖。
  根源就在自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
  把上面的代码改成以下任何一种都可以达到效果:

  1. 采用synchronized:
    public synchronized void increase() {
        inc++;
    }
  1. 采用Lock:
    private Lock lock = new ReentrantLock();
    public void increase() {
        lock.lock();
        try {
            inc++;
        }catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }

    }
  1. 采用AtomicInteger:
public volatile AtomicInteger inc =new AtomicInteger();

 public void increase() {
    inc.getAndIncrement();
 }

3.3 使用volatile关键字的场景

  synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率。而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized的。因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须保证操作是原子性操作(例如直接赋值),才能保证使用volatile关键字的程序在并发时能够正确执行。
  例如:1. 状态标记量

volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}
volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            
 
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

  例如:2. double check

class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

4. synchronized

  Java语言的关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。然而,当一个线程访问object的一个加锁代码块时,另一个线程仍然可以访问该object中的非加锁代码块。

  1. 当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
  2. 当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其它synchronized(this)同步代码块的访问将被阻塞。
  3. 然而,当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的除synchronized(this)同步代码块以外的部分。
  4. 当一个线程访问object的一个synchronized(this)同步代码块时,它就获得了这个object的对象锁。结果,其它线程对该object对象所有同步代码部分的访问都被暂时阻塞。
  5. 以上规则对其它对象锁同样适用。
  • wait() 与notify()/notifyAll()
      wait():释放占有的对象锁,线程进入等待池,释放cpu,而其他正在等待的线程即可抢占此锁,获得锁的线程即可运行程序。而**sleep()**不同的是,线程调用此方法后,会休眠一段时间,休眠期间,会暂时释放cpu,但并不释放对象锁。也就是说,在休眠期间,其他线程依然无法进入此代码内部。休眠结束,线程重新获得cpu,执行代码。wait()和sleep()最大的不同在于wait()会释放对象锁,而sleep()不会!
      notify(): 该方法会唤醒因为调用对象的wait()而等待的线程,其实就是对对象锁的唤醒,从而使得wait()的线程可以有机会获取对象锁。调用notify()后,并不会立即释放锁,而是继续执行当前代码,直到synchronized中的代码全部执行完毕,才会释放对象锁。JVM则会在等待的线程中调度一个线程去获得对象锁,执行代码。需要注意的是,wait()和notify()必须在synchronized代码块中调用。
      notifyAll()则是唤醒所有等待的线程。

5. lock

5.1 synchronized的缺陷

  synchronized是java中的一个关键字,也就是说是Java语言内置的特性。那么为什么会出现Lock呢?
  如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
  1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
  2)线程执行发生异常,此时JVM会让线程自动释放锁。
  那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待,试想一下,这多么影响程序执行效率。
  因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到
  通过Lock可以知道线程有没有成功获取到锁。这个是synchronized无法办到的。
  
  总结一下,也就是说Lock提供了比synchronized更多的功能。但是要注意以下几点:
  1)Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;
  2)Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

5.2 用法

  1. lock进行同步
Lock lock = ...;
lock.lock();
try{
    //处理任务
}catch(Exception ex){

}finally{
    lock.unlock();   //释放锁
}
  1. trylock使用方法:
Lock lock = ...;
if(lock.tryLock()) {
     try{
         //处理任务
     }catch(Exception ex){

     }finally{
         lock.unlock();   //释放锁
     } 
}else {
    //如果不能获取锁,则直接做其他事情
}
  1. ockInterruptibly()一般的使用形式如下:
public void method() throws InterruptedException {
    lock.lockInterruptibly();
    try {  
     //.....
    }
    finally {
        lock.unlock();
    }  
}

  注意:
  当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为本身在前面的文章中讲过单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。
  而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。

5.3 ReentrantLock

  ReentrantLock意思是“可重入锁”,是唯一实现了Lock接口的类,并且提供了更多的方法。
  如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。举个简单的例子,当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。

class MyClass {
    public synchronized void method1() {
        method2();
    }

    public synchronized void method2() {

    }
}

  上述代码中的两个方法method1和method2都用synchronized修饰了,假如某一时刻,线程A执行到了method1,此时线程A获取了这个对象的锁,而由于method2也是synchronized方法,假如synchronized不具备可重入性,此时线程A需要重新申请锁。但是这就会造成一个问题,因为线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样就会线程A一直等待永远不会获取到的锁。
  而由于synchronized和Lock都具备可重入性,所以不会发生上述现象。

6. volatile和synchronized区别

  1. volatile本质是在告诉jvm当前变量在寄存器中的值是不确定的,需要从主存中读取,synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住.
  2. volatile仅能使用在变量级别,synchronized则可以使用在变量,方法.
  3. volatile仅能实现变量的修改可见性,而synchronized则可以保证变量的修改可见性和原子性.
  4. volatile不会造成线程的阻塞,而synchronized可能会造成线程的阻塞.
  5. 当一个域的值依赖于它之前的值时,volatile就无法工作了,如n=n+1,n++等。如果某个域的值受到其他域的值的限制,那么volatile也无法工作,如Range类的lower和upper边界,必须遵循lower<=upper的限制。
  6. 使用volatile而不是synchronized的唯一安全的情况是类中只有一个可变的域。

7. synchronized和lock区别

  1. Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
  2. synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
  3. Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
  4. 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
  5. Lock可以提高多个线程进行读操作的效率。

  在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值