java中的线程同步机制讲解

锁的概述

一个共享数据同时只能被一个线程所访问,该功能依赖的条件就是锁。锁是对共享数据的保护,任何线程访问共享数据前必须拿到锁才能访问,并且锁只有一把,只有一个线程能拿到,访问结束后必须释放锁,让其他线程可持有该锁并继续访问。
线程拿到锁的过程叫锁的获取,该线程称为锁的持有线程,访问共享数据结束后称锁的释放,在持有锁和释放锁期间执行的代码叫做临界区。因此,共享数据只能放在临界区访问,临界区只能同时有一个线程访问。

锁的作用

上一篇文章讲述了volatile保证原子性、可见性、有序性的原理,有疑惑同学的可以参考这篇文章:
多线程原子性、可见性、有序性的验证

保证原子性

锁保证原子性的方法就是互斥,将多线程访问共享变量变成单线程访问,保证临界区内的代码只有一个线程访问,其他被锁在临界区外面的线程看不到操作的中间值。

保证可见性

上面的文章讲解了刷新处理器缓存和冲刷处理器缓存的概念,锁保证可见性的原理是一样的,在获取锁的时候,就会刷新处理器缓存,保证更新的是最新的数据,在释放锁的时候,会冲刷处理器缓存,保证更新完后,被其他线程看到的数据是最新的。

保证有序性

原子性已经保证了有序性。写线程在临界区内的执行的代码仍然会发生重排序,但是在读线程来看,它不关心有序还是无序,它只关心结果,这就是原子性保证了有序性。

b = a+1;
c=2
flag =true

如上面的代码出现在临界区,A线程是写线程,对上述变量进行了操作,B线程是读线程,它在获取这些变量的值时,b、c、flag这些变量一定是同时改变的。

锁的分类

内部锁 synchronized

概念

上文说到持有锁的线程访问临界区的时候会申请锁,执行完临界区代码之后会释放锁,synchronized之所以被成为内部锁,是因为申请锁和释放锁的过程是由java虚拟机来实时的,不需要人为手动的操作,所以被称为内部锁。

synchronized是用来修饰对象的,每一个对象都会有唯一的一把锁,这把锁就可以理解为是通过synchronized修饰实现的,synchronized修饰的对象叫做锁句柄,临界区用{}来修饰。
在这里插入图片描述

原理

每一个内部锁都有一个入口集(Entry Set),用来记录等待获取锁的线程,当多个线程同时申请一个对象的锁时,只有一个线程可以成为锁的持有线程,而其他线程就会进入到入口集中,变成BLOCKED状态,并等待再次申请锁。入口集中线程就被成为内部锁的等待线程,当这把锁的持有线程释放锁之后,这些等待线程就会被java虚拟机随机唤醒一个,从而得到再次申请锁的机会。
如上的过程是非公平的,被唤醒的线程也有可能被新的RUNNABLE线程抢占锁,并且java虚拟机唤醒等待集中的线程是随机的。

使用

当使用synchronized修饰非static对象时,锁的即是当前对象,修饰方法和对象的效果是一样的,如下两个是等同的:

 void test(){
        synchronized (this){

        }
    }
 synchronized void test() {

    }

当synchronized修饰static对象时,锁的是当前类,如下两个是等同的

 void test() {
        synchronized (SynchronizedTest.class) {

        }
    }
 static synchronized void test() {

}
可重入性

synchronized是具有可重入性的,先说一下可重入行的概念,即一个线程在获取锁之后,并未释放,但仍然可以再次获取这把锁。它的作用是为了保证某一个代码块的内容只能被一个线程访问。
下面的例子就可以说明synchronized为什么要具有可重入性

public class Widget{
    public synchronized void doSomething(){
        ........
    }
}

public class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        super.doSomething();
    }
}

当线程访问LoggingWidget 的doSomething方法时,还会访问它父类的方法,如果synchronized设计为不可重入锁,则会导致锁的泄漏,即当前线程永远无法执行结束,被阻塞在父方法外,并且也会阻塞其他线程的执行。

每一个可重入锁中有一个计数器,初始值为0,表示当前没有被任何线程持有,当被一个线程持有时,计数器会加1,当该线程释放的时候,计数器会减1,直到计数器为0的时候,该锁就会被释放。该线程初次获取锁的开销是比较大的,因为该线程需要竞争,进行上线文切换,而再次获取锁的开销相对较小,计数器加1即可。

显示锁

使用

显示锁是java.util.concurrent.locks.Lock接口的实例。本篇文章主要讲述的是它的一个实现类—ReentrantLock。通过名字可知它是一个可重入锁,访问共享数据之前执行Lock.lock()方法,结束共享变量的访问之后执行Lock.unlock()方法,需要注意的是,为了防止锁的泄漏,最后一定要将Lock.unlock()放到finally中。如果当前线程在临界区中,如果其他线程这时候来访问,则会被阻塞。

lock.lock()

public class LockTest {
    private final Lock lock = new ReentrantLock();

    public void test() {
        lock.lock();
        try {
            //do something
        } finally {
            lock.unlock();
        }
    }
}

lock.tryLock()
该方法是尝试获取这把锁,如果成功获取,就返回true,并可以执行临界区中的代码,如果没有获取(被其他线程抢占),则返回false,跳过临界区的代码,继续执行。该方法不会导致获取锁失败的线程被BLOCKED住。

public class LockTest {
    private final Lock lock = new ReentrantLock();

    public void test() {
        if (lock.tryLock()) {
            try {
                //do something
            } finally {
                lock.unlock();
            }
        }
        //go on...
    }
}

lock.tryLock(long time, TimeUnit unit)
该方法与lock.tryLock()的区别在于多了时间参数,表示如果当前线程在设置的指定时间内获取锁则可以执行临界区中的代码,在指定时间内没有获得所则返回false,放弃执行临界区的代码

public class LockTest {
    private final Lock lock = new ReentrantLock();

    public void test() {
        try {
            //尝试20ms,如果成功获取就执行,否则就 go on...
            if (lock.tryLock(20, TimeUnit.MILLISECONDS)) {
                try {
                    //do something
                } finally {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //go on...
    }
}

lock.isLocked()、lock.isHeldByCurrentThread()、lock.getQueueLength()
lock.isLocked()用来判断当前锁是否有线程在使用
lock.isHeldByCurrentThread()用来判断当前线程是否是锁的持有者
lock.getQueueLength()用来判断有几个线程被阻塞

public class LockTest {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            test();
        }).start();

        Thread.sleep(1000);
        //查看该锁是否被任意线程持有,打印结果为true,子线程持有锁
        System.out.println(Thread.currentThread().getName() + lock.isLocked());
        lock.lock();
        try {

        } finally {
            lock.unlock();
        }
        //查看该锁是否被任意线程持有,打印结果为false
        System.out.println(Thread.currentThread().getName() + lock.isLocked());
        //是否被当前线程持有,打印为false
        System.out.println(Thread.currentThread().getName() + lock.isHeldByCurrentThread());
    }

    public static void test() {
        try {
            //尝试20ms,如果成功获取就执行,否则就 go on...
            if (lock.tryLock(20, TimeUnit.MILLISECONDS)) {
                //是否被当前线程持有,打印为true
                System.out.println(Thread.currentThread().getName()+lock.isHeldByCurrentThread());
                Thread.sleep(2000);
                //有几个线程被阻塞,只有main线程1个,打印结果为1
                System.out.println(Thread.currentThread().getName()+lock.getQueueLength());
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    //go on...
}

内存屏障

java虚拟机实现上述刷新处理器缓存和冲刷处理器缓存的原理就是借助内存屏障来实现的。

概念

内存屏障是只针对内存读、写操作指令的跨处理器架构的比较底层的抽象,内存屏障是插入到两个指令之间使用的,作用是禁止重排序从而保证有序性,它之所以叫做屏障。是因为就像在指令1和指令2之间存在的一堵墙,从而使两侧的指令无法穿越。
但是为了禁止重排序,内存屏障还有一个作用,那就是刷新处理器缓存和冲刷处理器缓存,从而保障了可见性。

分类

按照内存屏障的作用来分,可以从两个角度来划分。

按可见性划分

按照可见性的保障来划分,内存屏障可分为加载屏障(Load Barrier)和存储屏障(Store Barrier)。加载屏障作用是刷新处理器缓存,存储屏障是冲刷处理器缓存。java虚拟机会在申请锁(Monitor Enter)对应的机器码指令后,临界区开始前插入一个加载屏障,保证读线程拿到的共享变量是其他写线程完成的最新数据;同理,java虚拟机会在释放锁(Monitor Exit)对应的机器码指令之后插入一个存储屏障,这就保证了写线程释放锁后,可以将共享变量的最新数据同步到读线程的高速缓冲区中。
注意:拿到锁的操作是从指令的角度出发,并不是从代码的角度出发,代码可能就一行,但是对应指令有很多行。
在这里插入图片描述

按有序性划分

按照有序性来划分可以将内存屏障分为获取屏障(Acquire Barrier)和释放屏障(Release Barrier)。
获取屏障是在共享变量的读操作后插入的,目的是禁止该读操作与之后的任何读、写操作进行重排序,保证不会先读到之后的内容,在进行后续操作之前一定要先获取到该共享变量,这也是获取屏障名字的由来;释放屏障是在写操作之前插入的,目的是禁止该写操作与前面之前的读、写操作进行重排序,保证前面的内容不会写到这里,要先对其他共享数据的操作释放后,在进行volatile的写操作,这是释放屏障名字的由来。
jvm会在获取锁之后(包含了读操作),临界区开始之前插入一个获取屏障,目的是禁止临界区中的代码被重排序到临界区之前;在临界区结束后,在释放锁前(包含了写操作)插入一个释放屏障,目的是禁止临界区中的代码被重排序到临界区之后。

在这里插入图片描述

volatile关键字

概念

volatile的意思是易挥发、不稳定的,即表示用volatile修饰的变量的值是很容易发生变化的,也就是说对volatile修饰变量的读取操作必须从高速缓存中获取。它之所以称之为轻量级锁,是因为它具有和锁相同的功能:保证可见性、有序性、原子性(只能保证写操作的原子性),但是又不会引起上下文的切换,这是被称为“轻量级”的原因。

原理

volatile保证了可见性和有序性的原理也是依赖了上面提到得内存屏障。
对于volatile变量的写操作,jvm会在该操作之前插入一个释放屏障,保证之前的代码一定先于volatile修饰的变量操作之前执行,不能让volatile变量先执行。jvm还会在volatile写操作之后,插入一个存储屏障,保证更新变量的值能刷新到缓存当中,其他读线程能拿到最新的值。
在这里插入图片描述

对于volatile变量的读操作,jvm会在它之前加一个加载屏障,目的是读到的值一定是最新的,一定是从高速缓存中读取的;jvm还会在它之后加一个获取屏障,目的不能与该屏障之后的读写操作进行重排序,告诉程序,一定要先读我。
在这里插入图片描述

使用场景

保证原子性、可见性、有序性
除此之外,还可以用volatile来做一个简单的读写锁

public class Counter {

    private volatile long count;
    public long value(){
        return count;
    }
    public void increment(){
        synchronized (this){
            count++;
        }
    }
}

count用volatile来修饰,目的是允多个线程可以进行读操作,但是并不影响它的写操作。如果用读写锁的话,那当读取count值的时候,是不能进行写操作的。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值