可见性和原子性的实现

一、可见性与原子性的实现

可见性与原子性是影响多线程安全的两个重要特性。Java内存模型定义了volatile和synchronized的行为,确保了一个正确同步的java程序可以在所有处理器架构上正确运行。

导致共享变量在线程间不可见的原因有三个:
①线程的交叉执行
②重排序结合线程交叉执行
③共享变量更新后的值没有在工作内存与主内存间即使刷新。
如何解决这些问题呢?①②可以原子性解决,③可用可见性解决。我们来看一下。

(1)synchronized实现可见性和原子性

1.1.1 synchronized的使用
  synchronized可以为一段操作或内存进行加锁,它具有互斥性。当线程要操作被synchronized修饰的内存或操作时,必须首先获得锁才能进行后续操作。但是在同一时刻只能有一个线程获得相同的一把锁(对象监视器),所以它只允许一个线程进行操作。如果“钥匙”不在原处,则该线程需要等待别人把钥匙放回来(等待即进入阻塞状态);如果多个线程要获取该钥匙,则它们需要进行“竞争”(一般是根据线程的优先级进行竞争)。synchronized是一种隐式锁
  synchronized关键字是不能继承的,也就是说,基类的方法synchronized f(){} 在继承类中并不自动是synchronized f(){},而是变成了f(){}。继承类需要你显式的指定它的某个方法为synchronized方法。
  synchronized也可以实现可见性。对于可见性,JMM关于synchronized有两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中。
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意:加锁和解锁需要同一把锁)。线程解锁前对共享变量的修改在下次加锁时对其他线程可见。

【代码示例】

//共享变量
private boolean ready = false;
private int result = 0;
private int number = 1;

//读操作
public synchronized void write(){
    ready = true;
    number = 2;
}
//写操作
public synchronized void read(){
    if(ready)
       result = number*3;

    System.out.println("result的值为"+result);
}

1.1.2 synchronized关键字的作用域

a. 某个对象实例内
  synchronized safeMethod(){}可以防止多个线程同时访问这个对象的synchronized 方法。这时,不同的对象实例的synchronized方法是不相干扰的。也就是说,其它线程照样可以同时访问相同类的另一个对象实例中的synchronized方法。

 //在方法上加锁
 public synchronized void safeMethod() {
    //相关操作
 } 

b. 某个类的范围
  synchronized static aStaticMethod{}防止多个线程同时访问这个类中的synchronized static 方法。它可以对类的所有对象实例起作用。

c. 方法中的某个区块中
  若将一个大的方法声明为synchronized将会大大影响效率,我们可以将synchronized关键字还可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。

 //在对象上加锁
 synchronized(syncObject){
    /**区块**/
 } 

如果syncObject是this的话,那么this指的就是调用这个方法的对象,那么作用域是当前对象。举个代码的栗子:

public class Thread1 implements Runnable {
  public void run() {
   synchronized(this) { 
     for (int i = 0; i < 5; i++) 
      System.out.println(Thread.currentThread().getName() + " synchronized loop " + i);
    } 
  } 
  public static void main(String[] args) {
    Thread1 t1 = new Thread1();
    Thread ta  = new Thread(t1, "A");
    Thread tb  = new Thread(t1, "B");
    ta.start();
    tb.start(); 
  } 
}

运行结果是这样:

A synchronized loop 0
A synchronized loop 1
A synchronized loop 2
A synchronized loop 3
A synchronized loop 4
B synchronized loop 0
B synchronized loop 1
B synchronized loop 2
B synchronized loop 3
B synchronized loop 4

不加synchronized(this)的话是这样:

-- 有好多种结果,这里只是其中一种
A synchronized loop 0
B synchronized loop 0
A synchronized loop 1
B synchronized loop 1
A synchronized loop 2
B synchronized loop 2
A synchronized loop 3
B synchronized loop 3
B synchronized loop 4
A synchronized loop 4

也就是说有了synchronized(this)之后,不管ta还是tb,刚开始运行这段代码的时候会给这段代码加个锁,这样即使运行到中间被替换了,另一个线程也不会执行这段代码,因为这段代码加锁了,而钥匙在给代码加锁的那个线程手里,只有加锁的线程运行完这段代码,才会给代码解锁.然后其他线程才能执行这段代码。

1.1.3 使用synchronized注意事项

a. 无论synchronized关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁。

b. 每个对象只有一个锁(lock)与之相关联。

c. 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

d. 如果有synchronized的关键字就一定能够保证共享变量的修改能够及时被其他线程所看到。但是反过来,Java内存并不是不加synchronized关键字共享变量就一定不可见,其实我们不加synchronized的关键字修改的变量也有可能被其他线程所看到。事实上没有加synchronized的关键字,共享变量依然能够在主内存和工作内存之间得到及时的更新,这个主要是因为编译器做了一些优化,它会去揣摩程序的意图。也许你运行很多次,才会出现那么一次变量在主内存和工作内存之间更新不及时的情况(一般只有在短时间内高并发的情况下才会出现变量得不到及时更新,因为CPU在执行时会很快地刷新内存),但是也就是这么一次情况,可能就会带来很多严重的后果,所以写程序的时候最好在需要保证可见性的地方加一些安全措施。

1.1.5 synchronized的限制
  synchronized是不错,但它并不完美。它有一些功能性的限制:

  • 它不能中断一个正在试图获得锁的线程(内部锁的特性),因为这是由CPU控制的,通过Java你无法控制CPU去中断正在试图获得锁的线程;
  • 无法通过投票得到锁,如果不想等下去,也就没法得到锁;
  • 同步还要求锁的释放只能在与获得锁所在的堆栈帧相同的堆栈帧中进行,多数情况下,这没问题(而且与异常处理交互得很好),但是,确实存在一些非块结构的锁定更合适的情况;
  • 因为Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间,对于代码简单的同步块,状态转换消耗的时间有可能比用户代码执行的时间还长,所以synchronized是Java语言中一个重量级(Heavyweight)锁,有经验的程序员都会在确实必要的情况下才使用这种操作。
(2)volatile实现可见性

volatile如何实现内存可见性?通过加入内存屏障禁止重排列优化来实现的。

  • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
  • 对volatile变量执行读操作时,会在写操作后加入一条load屏障指令

通俗地讲,就是volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样任何时刻,不同的线程总能看到该变量最新的值。

线程写volatile变量过程:

  1. 改变线程工作内存中volatile变量副本的值
  2. 将改变后的副本的值从工作内存刷新到主内存

线程读volatile变量过程:

  1. 从主内存中读取volatile变量的最新值到工作内存中
  2. 从工作内存中读取volatile变量副本

volatile虽然能够保证可见性,但是不能够保证原子性。所以被volatile修饰的变量可能会发生数据值与预想不一致的问题。但是如果volatile合理使用的话,将会比synchronized的执行成本更低,因为synchronized在多线程环境下会引起线程上下文切换及调度,在并发量大的前提下,有不小的性能开销。因此合理使用volatile有助于我们代码性能的优化。

(3)ReentrantLock实现原子性

ReentrantLock位于java.util.concurrent.locks包下,是一种显式锁可重入锁。顾名思义,这个锁可以被线程多次重复进入进行获取操作。

ReentantLock继承接口Lock并实现了接口中定义的方法,除了能完成synchronized所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。实现该接口的类提供了显式锁的功能,我们看看Lock接口:

void lock();        //尝试获取锁,若得不到着等待(不可中断,类似于synchronized方式)
void lockInterruptibly() ; //可中断地尝试获取锁
boolean tryLock();         //尝试获取锁,不管得到与否立即返回
boolean tryLock(long time, TimeUnit unit);  //尝试获取锁,若得不到等到一段时间
void unlock();             //释放锁
Condition newCondition();  //创建于该锁相关的条件变量,实现精确等待/唤醒

下面我们详细介绍有关ReentrantLock提供的各种锁与操作方式。

1. 公平锁与非公平锁
  ReentrantLock引入两个概念:公平锁与非公平锁。公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁。反之,JVM按随机、就近原则分配锁的机制则称为不公平锁(速度更快)。非公平锁实际执行的效率要远远超出公平锁,除非程序有特殊需要,否则最常用非公平锁的分配机制。
  如果发现该操作已经在执行,等待一个一个执行(同步执行)这种情况比较常见大家也都在用,主要是防止资源使用冲突,保证同一时间内只有一个操作可以使用该资源,类似于synchronized。但与synchronized的明显区别是性能优势(伴随jvm的优化这个差距在减小)。同时Lock有更灵活的锁定方式,公平锁与不公平锁,而synchronized永远是公平的。ReentrantLock默认情况下为不公平锁。
  在这种方法下,线程拿不到lock就不罢休,不然线程就一直block。 比较无赖的做法。

private ReentrantLock lock = new ReentrantLock();  //参数默认false,不公平锁
private ReentrantLock lock = new ReentrantLock(true); //公平锁
...  

try{  
  lock.lock();
  //其他操作
}finally{  
    //显示释放锁  
    lock.unlock();  
}  

2. 线程在等待资源过程中需要中断

ReentrantLock的在获取锁的过程中有2种锁机制,忽略中断锁响应中断锁

ReentrantLock.lock();              \\设置锁机制为忽略中断锁
ReentrantLock.lockInterruptibly(); \\设置锁机制为响应中断锁

响应中断是什么意思? 比如A、B两线程去竞争锁,A得到了锁,B等待,但是A有很多事情要处理,所以一直不返回。B可能就会等不及了,想中断自己,不再等待这个锁了,转而处理其他事情。在这种情况下,synchronized的做法是,B线程想中断自己(或者别的线程中断它),我不去响应,继续让B线程等待,你再怎么中断,我全当耳边风。而lockInterruptibly()的做法是,B线程想中断自己(或者别的线程中断它),ReentrantLock响应这个中断,不再让B等待这个锁的到来。有了这个机制,使用ReentrantLock时死锁了线程可以中断自己来解除死锁。

线程都有interrupt()方法。假设父线程创建的线程B的引用是b,那b.interrupt()就是中断线程B。

怎么中断?比如A、B两线程去竞争锁,我们中断了线程B,此时lockInterruptibly()这个方法会抛出异常InterruptedException。这时线程就进入了中断处理的过程,不会再等待锁了。但必须注意,此处响应中断锁是指正在获取锁的过程中,如果线程此时并非处于获取锁的状态,通过此方法设置是无法中断线程的。这种情况主要用于取消某些操作对资源的占用,如取消正在同步运行的操作,来防止不正常操作长时间占用造成的阻塞。

所以,当等待线程A或其他线程尝试中断线程A时,忽略中断锁机制则不会接收中断,而是继续处于等待状态;响应中断锁则会处理这个中断请求,并将线程A由阻塞状态唤醒为就绪状态,不再请求和等待资源。

package lock;
import java.util.concurrent.locks.ReentrantLock;
/**
 *
 * @Description: 实现了等待锁的时候,5秒没有获取到锁,中断等待,线程继续做其它事情。
 */
public class ReentrantLockTest {
    public static void main(String[] args) throws InterruptedException {
        BufferInterruptibly buff = new BufferInterruptibly();
     
        final Writer writer = new Writer(buff);
        final Reader reader = new Reader(buff);
     
        writer.start();
        Thread.sleep(1000);
        reader.start();
     
        new Thread(new Runnable() {
     
            @Override
            public void run() {
                long start = System.currentTimeMillis();
                for (;;) {
                    if (System.currentTimeMillis()
                            - start > 10000) {
                        System.out.println("不等了,尝试中断");
                        reader.interrupt();
                        break;
                    }
     
                }
     
            }
        }).start();
     
    }
   
    public static class BufferInterruptibly {
       
        private ReentrantLock lock = new ReentrantLock();
         
        public void write() {
            lock.lock();
            try {
                long startTime = System.currentTimeMillis();
                System.out.println("开始往这个buff写入数据…");
                for (;;)// 模拟要处理很长时间
                {
                    if (System.currentTimeMillis()
                            - startTime > Integer.MAX_VALUE)
                        break;
                }
                System.out.println("终于写完了");
            } finally {
                lock.unlock();
            }
        }
        public void read() throws InterruptedException {
            lock.lockInterruptibly();//注意这里,可以响应中断,抛出中断异常。
            try {
                System.out.println("从这个buff读数据");
            } finally {
                lock.unlock();
            }
        }
    }
   
    public static class Writer extends Thread {
       
        private BufferInterruptibly buff;
         
        public Writer(BufferInterruptibly buff) {
            this.buff = buff;
        }
         
        @Override
        public void run() {
            buff.write();
            System.out.println("写结束");
        }
         
    }
    public static class Reader extends Thread {
       
        private BufferInterruptibly buff;
         
        public Reader(BufferInterruptibly buff) {
            this.buff = buff;
        }
         
        @Override
        public void run() {
         
            try {
                buff.read();//可以收到中断的异常,从而有效退出
            } catch (InterruptedException e) {
                System.out.println("我不读了");
            }
               
            System.out.println("读结束,去做其它事情");
         
        }
         
    }
}

输出结果:

开始往这个buff写入数据…
不等了,尝试中断
我不读了
读结束

Lock实现的机理依赖于特殊的CPU指定,可以认为不受JVM的约束,并可以通过其他语言平台来完成底层的实现。在并发量较小的多线程应用程序中,ReentrantLock与synchronized性能相差无几,但在高并发量的条件下,synchronized性能会迅速下降几十倍,而ReentrantLock的性能却能依然维持一个水准,因此我们建议在高并发量情况下使用ReentrantLock。

3. 实现可轮询的锁请求
  在synchronized中,一旦发生死锁,唯一能够恢复的办法只能重新启动程序,唯一的预防方法是在设计程序时考虑完善不要出错。而有了Lock以后,死锁问题就有了新的预防办法,它提供了tryLock()轮询方法来获得锁,如果锁可用则获取锁,如果锁不可用,则此方法返回false,并不会为了等待锁而阻塞线程,这极大地降低了死锁情况的发生。典型使用语句如下:

private ReentrantLock lock = new ReentrantLock();
if(lock.tryLock()){
    //锁可用,则成功获取锁
    try {
        //获取锁后进行处理
    } finally {
        lock.unlock();
    }
} else {
    //锁不可用,其他处理方法
}

4、定时锁请求
  在synchronized中,一旦发起锁请求,该请求就不能停止了,如果不能获得锁,则当前线程会阻塞并等待获得锁。在某些情况下,你可能需要让线程在一定时间内去获得锁,如果在指定时间内无法获取锁,则让线程放弃锁请求,转而执行其他的操作。Lock就提供了定时锁的机制,使用Lock.tryLock(long timeout, TimeUnit unit)来指定让线程在timeout单位时间内去争取锁资源,如果超过这个时间仍然不能获得锁,则放弃锁请求,定时锁可以避免线程陷入死锁的境地。

try {
//如果已经被lock,尝试等待5s,看是否可以获得锁,如果5s后仍然无法获得锁则返回false继续执行
      if (lock.tryLock(5, TimeUnit.SECONDS)) {  
        try {
                //操作
             } finally {
                 lock.unlock();
             }
       }
  } catch (InterruptedException e) {
     //当前线程被中断时(interrupt),会抛InterruptedException       
     e.printStackTrace();           
  }
(4)原子包实现原子性

java.util.cocurrent.atomic这个包里面提供了一组原子变量类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入。

java.util.concurrent.atomic中的类可以分成4组:

分类 原子变量类
标量类(Scalar) AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference
数组类 AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
更新器类 AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
复合变量类 AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater

AtomicReference这四种基本类型用来处理布尔,整数,长整数,对象四种数据,其内部实现不是简单的使用synchronized,而是一个更为高效的方式CAS (compare and swap) + volatile和native方法,从而避免了synchronized的高开销,执行效率大为提升。

CAS操作需要输入两个值,一个旧值(执行CAS操作前的值,期望值)和一个新值,只有当当前值等于旧值时,才可以将当前值设置为新值,否则不设置。这是一个原子操作,由硬件保证。

(5)final关键字实现可见性

final变量的特殊之处在于,final变量一经初始化,就不能改变其值。这里的值对于一个对象或者数组来说指的是这个对象或者数组的引用地址。但有一点需要注意的是,当这个final变量为对象或者数组时,虽然我们不能将这个变量赋值为其他对象或者数组,但是我们可以改变对象的域或者数组中的元素。

final关键词在并发中的一个特殊应用是非常重要而且常常被忽视的,实际上,fianl 可以保证正在创建中的对象不能被其他线程访问到。反之,不适用final的对象,是可以在创建的过程中被访问到的。

通常情况下,final变量有3个地方可以赋值:直接赋值,构造函数中,或是初始化块中。final域只能被显式地赋值一次,可是这并不代表final不能被多次初始化。比如final int i在构造函数中被赋值之前,就会被初始化为默认的值0。

写(赋值)final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外。
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

假设线程B读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序:


可见性和原子性的实现_第1张图片

在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误的读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则“限定”在了构造函数之内,读线程B正确的读取了final变量初始化之后的值。

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程B“看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值2还没有写入普通域i)。

final 变量在并发当中,原理是通过禁止cpu的指令集重排序,来提供现成的可见性,来保证对象的安全发布,防止对象引用被其他线程在对象被完全构造完成前拿到并使用。

虽然final与volatile 有相似作用,不过final主要用于不可变变量(基本数据类型和非基本数据类型),进行安全的发布(初始化)。而volatile可以用于安全的发布不可变变量,也可以提供可变变量的可见性。如果一个对象将会在多个线程中访问并且你并没有将其成员声明为final,则必须提供其他方式保证线程安全。 “其他方式”可以包括声明成员为volatile,使用synchronized或者显式Lock控制所有该成员的访问。

二、同步的实现

实现同步可以通过:

  • 加synchronized关键字
      内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。
  • 使用volatile关键字
      volatile关键字为域变量的访问提供了一种免锁机制,使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,在需要同步的变量加上volatile。
  • 使用重入锁(ReentrantLock)实现线程同步
  • 使用局部变量实现线程同步,如ThreadLocal
      ThreadLocal与同步机制都是为了解决多线程中相同变量的访问冲突问题。
  • wait()和notifyAll()/notify()这一对方法。

wait, notify 和 notifyAll
  在 Java 中可以用 wait、notify 和 notifyAll 来实现线程间的通信。。举个例子,如果你的Java程序中有两个线程——即生产者和消费者,那么生产者可以通知消费者,让消费者开始消耗数据,因为队列缓冲区中有内容待消费(不为空)。相应的,消费者可以通知生产者可以开始生成更多的数据,因为当它消耗掉某些数据后缓冲区不再为满。
  我们可以利用wait()来让一个线程在某些条件下暂停运行。例如,在生产者消费者模型中,生产者线程在缓冲区为满的时候,消费者在缓冲区为空的时候,都应该暂停运行。如果某些线程在等待某些条件触发,那当那些条件为真时,你可以用 notify 和 notifyAll 来通知那些等待中的线程重新开始运行。不同之处在于,notify 仅仅通知一个线程,并且我们不知道哪个线程会收到通知,然而 notifyAll 会通知所有等待中的线程。换言之,如果只有一个线程在等待一个信号灯,notify和notifyAll都会通知到这个线程。但如果多个线程在等待这个信号灯,那么notify只会通知到其中一个,而其它线程并不会收到任何通知,而notifyAll会唤醒所有等待中的线程。
  调用wait()的时候,首先要检查当前线程是否获取到了这个对象上的锁。如果没有的话,就会直接抛出java.lang.IllegalMonitorStateException异常。如果有锁的话,就把当前线程添加到wait set中,并释放其所拥有的锁。当前线程被阻塞,无法继续执行,直到被从对象的等待集合中移除。
  所以wait/notify/notifyAll操作需要放在synchronized代码块或方法中,这样才能保证在执行 wait/notify/notifyAll的时候,当前线程已经获得了所需要的锁。此外,wait()应该永远在while循环,而不是if语句中调用wait。因为线程是在某些条件下等待的——在我们的例子里,即“如果缓冲区队列是满的话,那么生产者线程应该等待”,你可能直觉就会写一个if语句。但if语句存在一些微妙的小问题,导致即使条件没被满足,你的线程你也有可能被错误地唤醒。所以如果你不在线程被唤醒后再次使用while循环检查唤醒条件是否被满足,你的程序就有可能会出错——例如在缓冲区为满的时候生产者继续生成数据,或者缓冲区为空的时候消费者开始小号数据。

private Object lock = new Object();
synchronized (lock) { 
    while (/* 逻辑条件不满足的时候 */) { 
       try { 
           lock.wait();  
       } catch (InterruptedException e) {} 
    } 
    //处理逻辑
   lockObj.notifyAll();
}

三、线程安全

如果多线程的程序运行结果是可预期的,而且与单线程的程序运行结果一样,那么说明是“线程安全”的。

可见性和原子性的实现_第2张图片

  当ThreadA释放锁M时,它所写过的变量(比如,x和y,存在它工作内存中的)都会同步到主存中,而当ThreadB在申请同一个锁M时,ThreadB的工作内存会被设置为无效,然后ThreadB会重新从主存中加载它要访问的变量到它的工作内存中(这时x=1,y=1,是ThreadA中修改过的最新的值)。通过这样的方式来实现ThreadA到ThreadB的线程间的通信。

你可能感兴趣的:(可见性和原子性的实现)