Java 基础 —— 线程安全

一、线程安全问题

线程安全

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

一个程序在运行起来的时候会转换成进程,通常含有多个线程。通常情况下,一个进程中的比较耗时的操作(如长循环、文件上传下载、网络资源获取等),往往会采用多线程来解决。又比如实际生活中,银行取钱问题、火车票多个售票窗口的问题,通常会涉及到并发的问题,从而需要多线程的技术。
当进程中有多个并发线程进入一个重要数据的代码块时,在修改数据的过程中,很有可能引发线程安全问题,从而造成数据异常。例如,正常逻辑下,同一个编号的火车票只能售出一次,却由于线程安全问题而被多次售出,从而引起实际业务异常。

线程安全问题产生的原因——共享内存数据

线程、主内存、工作内存三者的关系如图:
Java 基础 —— 线程安全_第1张图片

在 Java 内存模型中,分为主内存和线程工作内存。每条线程有自己的工作内存,线程使用共享数据时,都是先从主内存中拷贝到工作内存,线程对该变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,线程使用完成之后再写入主内存不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
在多线程环境下,不同线程对同一份数据操作,就可能会产生不同线程中数据状态不一致的情况,这就是线程安全问题的原因。

二、线程安全方法

保证线程安全以是否需要同步手段分类,分为同步方案和无需同步方案。
Java 基础 —— 线程安全_第2张图片

1、互斥同步(阻塞同步)

互斥同步是最常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

互斥同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一个线程使用,而其他线程需要阻塞等待。

Java 实现互斥同步最基本的手段就是 synchronized 关键字,属于重量级锁。synchronized 关键字编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码质量,这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。synchronized 有两点基本特性:

(1)synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的情况
(2)同步块在已进入的线程执行完毕前,会阻塞后面其他线程的进入,synchronized 是非公平锁。

Java 中,ReentrantLock 也是通过互斥来实现同步。在基本用法上,ReentrantLock 与 synchronized 很相似,他们都具备一样的线程重入特性。

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。

2、非阻塞同步

随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
非阻塞同步需要硬件支持,因为操作和冲突检测这两个步骤需要具备原子性,而而需要靠硬件来保证。

非阻塞的实现——CAS(compareandswap)

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

CAS 缺点——ABA问题

因为 CAS 需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。
ABA 问题的解决思路就是使用版本号:在变量前面追加版本号,每次变量更新的时候把版本号加一,那么 A-B-A 就变成了 1A-2B-3C。JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值

3、非同步——可重入代码

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步操作去保证正确性,因此会有一些代码天生就是线程安全的。

(1)可重入代码

可重入代码(ReentrantCode)也称为纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
可重入代码的特点是不依赖存储在堆上的数据和公用的系统资源、用到的状态量都是由参数中传入、不调用非可重入的方法等。(类比:synchronized 拥有锁重入的功能,也就是在使用 synchronized 时,当一个线程得到一个对象锁后,再次请求此对象锁时时可以再次得到该对象的锁)

(2)线程本地存储

如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内。这样无需同步也能保证线程之间不出现数据的争用问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。
其中最重要的一个应用实例就是经典的Web交互模型中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式,这种处理方式的广泛应用使得很多Web服务器应用都可以使用线程本地存储来解决线程安全问题。

三、Java 实现线程安全的方法

要实现线程安全,需要保证数据操作的两个特性:

(1)原子性:对数据的操作不会受其他线程打断,意味着一个线程操作数据过程中不会插入其他线程对数据的操作
(2)可见性:当线程修改了数据的状态时,能够立即被其他线程知晓,即数据修改后会立即写入主内存,后续其他线程读取时就能得知数据的变化

以上两个特性结合起来,其实就相当于同一时刻只能有一个线程去进行数据操作并将结果写入主存,这样就保证了线程安全。

1、Volatile 保证可见性

volatile 有两点特性:第一是保证变量对所有线程的可见性,对于普通变量,一个线程对其修改后并不保证会立即写入主存,只有当写入主存之后才会对其他线程可见,而 volatile 关键字能够保证线程修改完变量立即写回主存,而且每个线程在使用变量前都必须先从主存刷新数据,这样就保证了修改的可见性;第二点是禁止指令的重排序,Java 编译器会对无结果依赖的代码指令进行重排序。

volatile 虽然保证了可见性,但并没有保证操作的原子性,还是会出现 A、B 线程同时读取到共享数据,然后各自修改导致结果覆盖的问题,所以无法保证线程安全

volatile 实际适用场景主要可以结合 CAS 操作,提高并发的性能,就如同 Java concurrent 类库中的用法,因为 volatile 的性能是很轻量的,能够避免 CAS 操作因为数据的可见性问题而导致失败。

2、线程同步 — Synchronized 锁

synchronized 是 Java 中的关键字,是一种同步锁。它修饰的对象有以下几种:

(1)修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,锁是 synchronized 括号里配置的对象
(2)修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,锁这个方法所在的当前实例对象
(3)修改一个静态的方法,其作用的范围是整个静态方法,锁是这个类的所有对象
(4)修改一个类,其作用的范围是 synchronized 后面括号括起来的部分,锁是这个类的所有对象

(1)修饰一个代码块

一个线程访问一个对象中的 synchronized(this) 同步代码块时,其他试图访问该对象的线程将被阻塞。

package com.wbg;

import java.util.ArrayList;
import java.util.List;

public class kt {
    public static void main(String[] args) {
        System.out.println("使用关键字synchronized");
        SyncThread syncThread = new SyncThread();
        Thread thread1 = new Thread(syncThread, "SyncThread1");
        Thread thread2 = new Thread(syncThread, "SyncThread2");
        thread1.start();
        thread2.start();
    }
}
class SyncThread implements Runnable {
    private static int count;
    public SyncThread() {
        count = 0;
    }
    public  void run() {
       synchronized (this){
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println("线程名:"+Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    public int getCount() {
        return count;
    }
}

运行结果
Java 基础 —— 线程安全_第3张图片
若不使用关键字 synchronized 运行结果
Java 基础 —— 线程安全_第4张图片
当两个并发线程(thread1 和 thread2)访问同一个对象(syncThread)中的 synchronized 代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。Thread1 和 thread2 是互斥的,因为在执行 synchronized 代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象。

注:synchronized 只锁定对象,多个线程要实现同步,所以线程必须以同一个 Runnable 对象为运行对象

public static void main(String[] args) {
        System.out.println("使用关键字synchronized每次调用进行new SyncThread()");
        SyncThread syncThread1 = new SyncThread();
        SyncThread syncThread2 = new SyncThread();
        Thread thread1 = new Thread(syncThread1, "SyncThread1");
        Thread thread2 = new Thread(syncThread2, "SyncThread2");
        thread1.start();
        thread2.start();
    } 

运行结果:
Java 基础 —— 线程安全_第5张图片
这时创建了两个 SyncThread 的对象 syncThread1 和 syncThread2,线程 thread1 执行的是 syncThread1 对象中的 synchronized 代码(run),而线程 thread2 执行的是 syncThread2 对象中的 synchronized 代码(run);我们知道 synchronized 锁定的是对象,这时会有两把锁分别锁定 syncThread1 对象和 syncThread2 对象,而这两把锁是互不干扰的,不形成互斥,所以两个线程可以同时执行。

当一个线程访问对象的一个 synchronized(this) 同步代码块时,另一个线程仍然可以访问该对象中的非 synchronized(this) 同步代码块

public static void main(String[] args) {
        System.out.println("使用关键字synchronized");
        Mthreads mt=new Mthreads();
        Thread thread1 = new Thread(mt, "mt1");
        Thread thread2 = new Thread(mt, "mt2");
        thread1.start();
        thread2.start();
    }
}
class Mthreads implements Runnable{
    private int count;

    public Mthreads() {
        count = 0;
    }

    public void countAdd() {
        synchronized(this) {
            for (int i = 0; i < 5; i ++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    //非synchronized代码块,未对count进行读写操作,所以可以不用synchronized
    public void printCount() {
        for (int i = 0; i < 5; i ++) {
            try {
                System.out.println(Thread.currentThread().getName() + " count:" + count);
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void run() {
        String threadName = Thread.currentThread().getName();
        if (threadName.equals("mt1")) {
            countAdd();
        } else if (threadName.equals("mt2")) {
            printCount();
        }
    }

运行结果:
Java 基础 —— 线程安全_第6张图片
上面代码中 countAdd 是一个 synchronized 的,printCount 是非 synchronized 的。从上面的结果中可以看出一个线程访问一个对象的 synchronized 代码块时,别的线程可以访问该对象的非 synchronized 代码块而不受阻塞。

(2)修饰一个方法

Synchronized 修饰一个方法很简单,就是在方法的前面加 synchronized,public synchronized void method(){}; synchronized 修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。

Synchronized 作用于整个方法的写法:

写法一:

public synchronized void method()
{
   // todo
}

写法二:

public void method()
{
   synchronized(this) {
      
   }
}

写法一修饰的是一个方法,写法二修饰的是一个代码块,但写法一与写法二是等价的,都是锁定了整个方法时的内容。

在用synchronized修饰方法时要注意以下几点:

(1)synchronized 关键字不能继承。 虽然可以使用 synchronized 来定义方法,但 synchronized 并不属于方法定义的一部分,因此,synchronized 关键字不能被继承。如果在父类中的某个方法使用了 synchronized 关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上 synchronized 关键字才可以。
(2)当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了
(3)在定义接口方法时不能使用 synchronized 关键字。
(4)构造方法不能使用 synchronized 关键字,但可以使用 synchronized 代码块来进行同步

(3)synchronized 修饰静态方法

静态方法是属于类的而不属于对象的,synchronized 修饰的静态方法锁定的是这个类的所有对象,该类的所有对象用 synchronized 修饰的静态方法的用的是同一把锁

(4)synchronized 修饰一个类

效果和 synchronized 修饰静态方法是一样的,synchronized 作用于一个类时,是给这个类加锁,该类的所有对象用的是同一把锁

3、线程同步—lock 锁

java.util.concurrent.locks 包下常用的类与接口(lock 是 jdk 1.5 后新增的)
Java 基础 —— 线程安全_第7张图片

Lock 和 ReadWriteLock 是两大锁的根接口,Lock 代表实现类是 ReentrantLock(可重入锁),ReadWriteLock(读写锁)的代表实现类是 ReentrantReadWriteLock。

1)Lock 接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括 hand-over-hand 和锁重排算法)中使用这些规则。主要的实现是 ReentrantLock。
2)ReadWriteLock 接口以类似方式定义了一些读取者可以共享而写入者独占的锁。此包只提供了一个实现,即 ReentrantReadWriteLock。但程序员可以创建自己的、适用于非标准要求的实现。
3)Condition 接口描述了可能会与锁有关联的条件变量。这些变量在用法上与使用 Object.wait 访问的隐式监视器类似,但提供了更强大的功能。需要特别指出的是,单个 Lock 可能与多个 Condition 对象关联。为了避免兼容性问题,Condition 方法的名称与对应的 Object 版本中的不同。

(1)Lock 锁与 synchronized 锁比较

1)使用 synchronized 关键字时,锁的控制和释放是在 synchronized 同步代码块的开始和结束位置。而在使用 Lock 实现同步时,锁的获取和释放可以在不同的代码块、不同的方法中。这一点是基于使用者手动获取和释放锁的特性。

2)Lock 接口提供了试图获取锁的 tryLock() 方法,在调用 tryLock() 获取锁失败时返回 false,这样线程可以执行其它的操作而不至于使线程进入休眠。tryLock() 方法可传入一个 long 型的时间参数,允许在一定的时间内来获取锁。

3)Lock 接口的实现类 ReentrantReadWriteLock 提供了读锁和写锁,允许多个线程获得读锁、而只能有一个线程获得写锁,读锁和写锁不能同时获得。实现了读和写的分离,这一点在需要并发读的应用中非常重要,如 lucene 允许多个线程读取索引数据进行查询但只能有一个线程负责索引数据的构建。

4)基于以上 3 点,lock 来实现同步具备更好的性能。

(2)Lock 接口提供的 synchronized 关键字不具备的主要特性:
Java 基础 —— 线程安全_第8张图片
(3)Lock 使用场景

如果一个代码块被 synchronized 关键字修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待直至占有锁的线程释放锁。事实上,占有锁的线程释放锁一般会是以下三种情况之一:占有锁的线程执行完了该代码块,然后释放对锁的占有;占有锁线程执行发生异常,此时 JVM 会让线程自动释放锁;占有锁线程进入 WAITING 状态从而释放锁,例如在该线程中调用wait()方法等。
以下三种场景只能用 Lock:

1)在使用 synchronized 关键字的情形下,假如占有锁的线程由于要等待 IO 或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。因此,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间 (解决方案:tryLock(long time, TimeUnit unit)) 或者 能够响应中断 (解决方案 :lockInterruptibly())),这种情况可以通过 Lock 解决。
2)当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用 synchronized 关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程在可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行读操作时,线程之间不会发生冲突。同样地,Lock也可以解决这种情况
(解决方案:ReentrantReadWriteLock) 。
3)我们可以通过 Lock 得知线程有没有成功获取到锁(解决方案:ReentrantLock) ,但这个是 synchronized 无法办到的。

(4) Lock 的简单使用

Lock lock=new ReentrantLock();
lock.lock();
try{
  }catch{
  ...
  }finally{
  lock.unlock();
   }

或者

Lock lock=new ReentrantLock();
lock.lock();
try{
  }catch{
  ...
   }
lock.unlock();

注意:1)不要把获取锁的过程写在 try 语句块中,因为如果在获取锁时发生了异常,异常抛出的同时也会导致锁无法被释放;
2)在 finally 语句块中释放锁的目的是保证获取到锁之后,最终能够被释放

(5)Lock 接口基本的方法
Java 基础 —— 线程安全_第9张图片

(6)ReentrantLock 类基本方法
Java 基础 —— 线程安全_第10张图片
Java 基础 —— 线程安全_第11张图片
(7)ReadWriteLock 接口

synchronized 和 ReentrantLock 实现的锁是排他锁,ReadWriteLock 是 JDK1.5 提供的读写分离锁,采用读写锁分离可以有效帮助减少锁竞争。ReadWriteLock 管理一组锁,一个是只读的锁,一个是写锁。它的特点是:

1)使用读锁:当线程只进行读操作时,可以允许多个线程同时读
2)使用写锁:写写操作、读写操作间依然需要相互等待和持有锁。

ReadWriteLock 接口主要有两个方法:

public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing
     */
    Lock writeLock(); 
  } 

Java 并发库中 ReetrantReadWriteLock 实现了 ReadWriteLock 接口并添加了可重入的特性。

(8)ReetrantReadWriteLock 类

ReentrantReadWriteLock 支持以下功能:

1)支持公平和非公平的获取锁的方式:当以非公平初始化时,读锁和写锁的获取的顺序是不确定的,非公平锁主张竞争获取,可能会延缓一个或多个读或写线程,但是会比公平锁有更高的吞吐量;当以公平模式初始化时,线程将会以队列的顺序获取锁,当当前线程释放锁后,等待时间最长的写锁线程就会被分配写锁,或者有一组读线程组等待时间比写线程长,那么这组读线程组将会被分配读锁。

2)支持可重入:读线程在获取了读锁后还可以获取读锁;写线程在获取了写锁之后既可以再次获取写锁又可以获取读锁;

3)还允许从写入锁降级为读取锁,其实现方式是:先获取写入锁,然后获取读取锁,最后释放写入锁。但是,从读取锁升级到写入锁是不允许的;

4)读锁和写锁都支持锁获取期间的中断

5)Condition 支持。仅写入锁提供了一个 Conditon 实现;读取锁不支持 Conditon ,readLock().newCondition() 会抛出 UnsupportedOperationException。

你可能感兴趣的:(Java,并发编程,java,线程安全)