线程理论:(三)线程安全的实现方法

定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调用和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

一、五类共享数据

1、不可变

  不可变的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。“不可变”带来的安全性是最简单和最纯粹的。 

  Java语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。

  如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响,例如java.lang.String类的对象,它是一个典型的不可变对象,我们调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

  JavaAPI中符合不可变要求的类型有:String、枚举类型、java.lang.Number的部分子类(Long、Double、BigInteger、BigDecimal等)。

2、绝对线程安全

  一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”,通常需要付出很大的,甚至有时候是不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

  java.util.Vector是一个线程安全的容器,它的add()、get()和size()这类方法都是被synchronized修饰的,尽管这样效率很低,但确实是安全的。但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段了。

private static Vector<Integer> vector = new Vector<>(); 
    
public static void main(String[] args) {
    while(true) {
        for (int i = 0; i < 100; i++) {
            vector.add(i);
        }
        
        Thread removeThread = new Thread(new Runnable() {
            @Override
            public void run() {
                //synchronized (vector) {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                //}
            }
        });
        
        Thread printThread = new Thread(new Runnable() {
            @Override
            public void run() {
                //synchronized (vector) {
                    for (int i = 0; i < vector.size(); i++) {
                        System.out.println(vector.get(i));
                    }
                //}
            }
        });
        
        removeThread.start();
        printThread.start();
        //不要同时产生过多的线程,否则会导致操作系统假死
        while(Thread.activeCount() > 20);
    }
}

  尽管Vector的方法都是同步的,但是在多线程环境中,如果不在方法调用端做额外的同步措施,使用这段代码仍然是不安全的。如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用的话,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException。所以要在必要的地方加上synchronized。 

3、相对线程安全

  我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。如上面的代码。

  在Java语言中,大部分的线程安全类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。 

4、线程兼容

  线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。

  Java API中大部分的类都是属于线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。 

5、线程对立

  线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。例Thread类的suspend()和resume()方法。

二、线程安全的实现方法

1、互斥同步

  互斥同步是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,互斥是因,同步是果;互斥是方法,同步是目的。

  在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止

  在虚拟机规范对monitorenter和monitorexit的行为描述中,有两点是需要特别注意的。首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。synchronized是Java语言中一个重量级的操作,有经验的程序员都会在确实必要的情况下才使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。

  除了synchronized之外,我们还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来完成),另一个表现为原生语法层面的互斥锁。

  不过,相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁,以及锁可以绑定多个条件。

  1)等待可中断,是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。

  2)公平锁,是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。

  3)锁绑定多个条件,是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的 wait() 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。

2、非阻塞同步

  互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

  从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

  随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。 

  CAS指令需要有3个操作数,分别是内存位置(变量的内存地址,V)、旧的预期值(A)和新值(B)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。

  在JDK 1.5之后,Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供,由于Unsafe类不是提供给用户程序调用的类(只有启动类加载器Bootstrap ClassLoader加载的Class才能访问它),因此,如果不采用反射手段,我们只能通过其他的Java API来间接使用它,如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。以下列代码为例。

import java.util.concurrent.atomic.AtomicInteger;

//AtomicInteger变量自增运算原子性测试
public class AtomicTest {
    public static AtomicInteger race = new AtomicInteger(0);
    
    public static void increase() {
        //输出正确结果,一切都要归功于incrementAndGet方法的原子性
        race.incrementAndGet();  
    }
    
    public static final int THREADS_COUNT = 20;
    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        
        while(Thread.activeCount() > 1) {
            Thread.yield();
        }
        
        System.out.println(race);
    }   
}

运行结果:
200000

  如果使用volatile变量race++的方式,是不能达到原子性的,可以对increase()方法用同步块包裹,也可以用AtomicInteger代替int,AtomicInteger的incrementAndGet()方法具有原子性,incrementAndGet()源码如下。

//incrementAndGet()源码
public final int incrementAndGet() {
    for(;;) {
        int current = get();
        int next = current + 1;
        if(compareAndSet(current,next)) {
            return next;
        }
    }
}

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

//valueOffset的值是变量value的内存首地址的偏移量。

  incrementAndGet()方法在一个无限循环中,如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。CAS相比Synchronized,避免了锁的使用,总体性能比Synchronized高很多。

  CAS存在被称为“ABA”逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类比较“鸡肋”,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

参考链接:

https://www.cnblogs.com/rever/p/8215743.html

https://www.imooc.com/article/25026?block_id=tuijian_wz

https://blog.csdn.net/u013404471/article/details/47297123

https://www.cnblogs.com/Mainz/p/3546347.html

3、无同步方案

  要保证线程安全,并不是一定就要进行同步,同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

  有一些代码天生就是线程安全的。

  可重入代码:这种代码也叫做纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

  线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。

  Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为“易变的”;如果一个变量要被某个线程独享,可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocal HashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

https://blog.csdn.net/wangdong5678999/article/details/80960197

https://www.cnblogs.com/wade-luffy/p/6052178.html

https://www.cnblogs.com/pacoson/p/5351355.html

https://www.cnblogs.com/skorzeny/p/6485692.html

https://www.cnblogs.com/kxdblog/p/4782502.html

https://blog.csdn.net/feirose/article/details/60958705

猜你喜欢

转载自www.cnblogs.com/zjxiang/p/9398497.html
今日推荐