当前位置:   article > 正文

Java并发之舞:掌握线程同步的精髓

Java并发之舞:掌握线程同步的精髓

线程同步

多个线程需要共享的同一数据进行存取,都会涉及到修改共享数据的状态,如果不加以控制,那么就会出现紊乱,发生共享资源冲突,线程同步指的是在一定的时间内只允许某一个线程访问某个资源。

Java 支持多个线程同时访问一个对象或者对象的成员变量,每个线程都可以用这个变量的拷贝,所以在线程的执行过程中所看到的变量的值不一定是最新的。这就是多线程带来的问题。

Java 线程同步方式

首先创建一段程序,模拟一个有若干账户的银行。随机生成在这些账户之间的转账交易每一个账户有一个线程。当这个模拟程序运行时,不清楚在某一时刻某一银行账户中有多少钱。但是,知道所有账户的总金额应该保持不变,因为所做的一切不过是从一个账户转移钱款到另一个账户。

public class Bank {
    private final double[] accounts;

    public Bank(int n, double initBalance) {
        accounts = new double[n];
        Arrays.fill(accounts, initBalance);
    }

    public void transfer(int from, int to, double amount) {
        if (accounts[from] < amount) return;
        accounts[from] -= amount;
        String name = Thread.currentThread().getName();
        System.out.printf("[%s]- 账户%d向账户%d转账 %.2f元", name, from, to, amount);
        accounts[to] += amount;
        System.out.printf(" --- 当前银行总金额:%.2f %n", getTotal());
    }

    public double getTotal() {
        double sum = 0;
        for (double account : accounts) {
            sum += account;
        }
        return sum;
    }

    public int size() {
        return accounts.length;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
public class ThreadBankDemo {

    public static final int ACCOUNTS_COUNT = 10;
    public static final double INIT_BALANCE = 100;
    public static final double MAX_AMOUNT = 100;
    public static final int DELAY = 10;

    public static void main(String[] args) {
        Bank bank = new Bank(ACCOUNTS_COUNT, INIT_BALANCE);
        for (int i = 0; i < ACCOUNTS_COUNT; i++) {
            int fromAccount = i;
            new Thread(() -> {
                try {
                    while (true) {
                        int toAccount = (int) (bank.size() * Math.random());
                        double amount = MAX_AMOUNT * Math.random();
                        bank.transfer(fromAccount, toAccount, amount);
                        Thread.sleep((long)(DELAY*Math.random()));
                    }
                } catch (InterruptedException e) {}

            }).start();
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

执行结果如下:

[Thread-9]- 账户9向账户8转账 91.24元 --- 当前银行总金额:1000.00 
[Thread-2]- 账户2向账户6转账 73.01元 --- 当前银行总金额:1000.00 
[Thread-8]- 账户8向账户2转账 99.40元 --- 当前银行总金额:1000.00 
[Thread-6]- 账户6向账户6转账 72.48元 --- 当前银行总金额:1000.00 
[Thread-3]- 账户3向账户9转账 4.08元 --- 当前银行总金额:1000.00 
[Thread-1]- 账户1向账户3转账 2.85元 --- 当前银行总金额:1000.00 
[Thread-5]- 账户5向账户2转账 96.01元 --- 当前银行总金额:1000.00 
[Thread-9]- 账户9向账户4转账 14.14元 --- 当前银行总金额:1000.00 
[Thread-1]- 账户1向账户0转账 47.69元 --- 当前银行总金额:988.20 
[Thread-6]- 账户6向账户0转账 11.80元 --- 当前银行总金额:1000.00 
[Thread-0]- 账户0向账户4转账 2.85元 --- 当前银行总金额:1000.00 
[Thread-2]- 账户2向账户7转账 21.24元 --- 当前银行总金额:1000.00 
[Thread-2]- 账户2向账户6转账 37.63元 --- 当前银行总金额:948.00 
[Thread-8]- 账户8向账户3转账 0.26元 --- 当前银行总金额:1000.00 
[Thread-2]- 账户2向账户3转账 72.41元 --- 当前银行总金额:973.00 
[Thread-9]- 账户9向账户6转账 35.80元 --- 当前银行总金额:1000.00 
[Thread-9]- 账户9向账户2转账 86.19元 --- 当前银行总金额:1000.00 
....
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

银行的余额保持在 1000, 这是正确 的, 因为共 100 个账户, 每个账户 $1 000。但是, 过一段时间,余额总量有轻微的变化。当运行这个程序的时候,会发现有时很快就出错了,有时很长的时间后余额发生混乱。当两个线程试图同时更新同一个账户的时候,这个问题就出现了。假定两个线程同时执行 accounts[to] += amount;

问题在于这不是原子操作,上面的指定可能会被处理如下:

  1. 将 accounts[to] 加载到寄存器
  2. 增加 amount
  3. 将结果写回 accounts[to]

现在,假定第 1 个线程执行步骤 1 和 2,然后它被剥夺了运行权。假定第 2 个线程被唤醒并修改了 accounts 数组中的同一项。然后,第 1 个线程被唤醒并完成其第 3 步。所以这一动作擦去了第二个线程所做的更新。

接下来我们需要使用几种线程同步的手段,问题的核心就是在 Bank 类的 transfer 方法,我们需要对它进行同步控制,保证在一定的时间内只允许某一个线程访问某个共享的资源(这里可以理解为Bank 的 accounts)。

synchronized 关键字

synchronized 可以修饰方法或者以同步块的方式进行使用,它主要确保多个线程在同一个时刻,只有一个线程处理方法或者同步块中,以此保证线程对于变量访问的可见性和排他性。

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的来实现的。挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程状态转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

如何使用 synchronized?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块
synchronized 同步方法

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。由于 Java 的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

public synchronized void transfer(int from, int to, double amount) {
    if (accounts[from] < amount) return;
    accounts[from] -= amount;
    String name = Thread.currentThread().getName();
    System.out.printf("[%s]- 账户%d向账户%d转账 %.2f元", name, from, to, amount);
    accounts[to] += amount;
    System.out.printf(" --- 当前银行总金额:%.2f %n", getTotal());
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
synchronized 同步静态方法

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {
    //业务代码
}
  • 1
  • 2
  • 3

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

synchronized 同步代码块

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁

同步代码块即有 synchronized 关键字修饰的语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用 synchronized 代码块同步关键代码即可。

尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

public void transfer(int from, int to, double amount) {
    if (accounts[from] < amount) return;
    synchronized (this) {
        accounts[from] -= amount;
        String name = Thread.currentThread().getName();
        System.out.printf("[%s]- 账户%d向账户%d转账 %.2f元", name, from, to, amount);
        accounts[to] += amount;
        System.out.printf(" --- 当前银行总金额:%.2f %n", getTotal());
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
构造方法可以用 synchronized 修饰么?

构造方法本身就属于线程安全的,不存在同步的构造方法一说。构造方法不能使用 synchronized 关键字修饰,不然编译器也会提示错误。

synchronized 底层原理

synchronized 关键字底层原理属于 JVM 层面的东西。

synchronized 在修饰代码块和修饰方法的时候,底层 JVM 的实现是不一样的。使用了 synchronized 的类,可以通过使用 javap 工具来查看生成的字节码文件来查看 synchronized 的视线细节。

首先当 synchronized 在修饰代码块的时通过查看 Java 的字节码文件,可以找到 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。尝试获取对象锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。对象锁的的拥有者线程可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

在 Java 每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

当synchronized 修饰方法的时候,在 Java 字节码中就会发现被修饰的方法加了一个 ACC_SYNCHRONIZED 的标识,如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

虽然上面两种方式的细节不一样,但是本质上都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步。换句话说,本质上是对一个对象监视器的获取,获取的过程是排他的,也就是同一个时刻,只有一个线程获取到 synchronized 所保护的对象监视器。

任意一个对象都有自己的监视器,线程获取对象监视器的过程是排他的,如果获取失败,则线程会进入同步队列,状态变为 BLOCKED。当持有对象监视器的线程释放了,会唤醒阻塞在同步对象的线程,使其又可以重新尝试获取对象监视器(获取的过程是竞争的,不一定就能获取到)。

JDK1.6 之后的 synchronized 底层做了哪些优化?

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好
  • volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性
synchronized 和 ReentrantLock 有什么区别?

两者都是可重入锁。指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

ReentrantLock 比 synchronized 增加了一些高级功能。主要来说主要有三点:

  • 等待可中断: ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁:ReentrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件):synchronized 关键字与 wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。

公平锁 : 锁被释放之后,先申请的线程/进程先得到锁。

非公平锁 :锁被释放之后,后申请的线程/进程可能会先获取到锁,是随机或者按照其他优先级排序的。

volatile 变量(轻量级)

volatile 可以修饰变量,即告知每个线程任何对于这个变量的访问都需要从共享内存中获取,而对于它的修改也必须要同步刷新回共享内存,以此就可以保证所有线程对变量访问的可见性。

volatile是 Java 提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块/方法和 volatile 变量,相比于 synchronized,volatile 更轻量级,因为它不会引起线程上下文的切换和调度。但是 volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

上面的程序 transfer 方法并不是针对 accounts 的原子操作,所以无法使用 volatile 解决同步问题。

如何禁止指令重排序?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

public class Singleton {
    
    private volatile static Singleton uniqueInstance;

    private Singleton() {}

    public  static Singleton getUniqueInstance() {
       // 先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            // 类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

比如有个场景:多个线程同时对共享变量 i 进行 i++ 操作,即使 i 被 volatile 修饰,但是 i++ 这个操作并不是原子性的,这其实是一个复合操作:

  1. 读取 i 的值
  2. 对 i 加 1
  3. 将 i 的值写回内存

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 i 进行读取操作之后,还未对其进行修改。线程 2 又读取了 i 的值并对其进行修改(+1),再将 i 的值写回内存
  2. 线程 2 操作完毕后,线程 1 对 i 的值进行修改(+1),再将 i 的值写回内存

这也就导致两个线程分别对 i 进行了一次自增操作后,i 实际上只增加了 1。

ReentrantLock 重入锁

在 JavaSE 5.0 中新增了一个 java.util.concurrent 包来支持同步。ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁,它与使用 synchronized 方法和块具有相同的基本行为和语义,并且扩展了其能力。

ReenreantLock 类的常用方法有:

  • ReentrantLock() : 创建一个 ReentrantLock 实例
  • lock() : 获得锁
  • unlock() : 释放锁,注意及时释放锁,否则会出现死锁,通常在 finally 代码块中释放锁
ReentrantLock lock = new ReentrantLock();

public void transfer(int from, int to, double amount) {
    if (accounts[from] < amount) return;
    lock.lock();
    try {
        accounts[from] -= amount;
        String name = Thread.currentThread().getName();
        System.out.printf("[%s]- 账户%d向账户%d转账 %.2f元", name, from, to, amount);
        accounts[to] += amount;
        System.out.printf(" --- 当前银行总金额:%.2f %n", getTotal());
    } finally {
        lock.unlock();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

在一些内置锁无法满足需求的情况下,ReentrantLock 可以作为一种高级工具。当需要一些高级功能时才应该使用 ReentrantLock,这些功能包括:可定时的,可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用 synchronized,synchronized 在后面的版本得到了很多优化,性能提高了很多,而且 synchronized 语法更加简洁。

ThreadLocal 线程本地变量

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?

JDK 中自带的 ThreadLocal 类正是为了解决这样的问题。 ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal 变量名的由来。他们可以使用 get()set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

public class Bank {
    private final ThreadLocal<Double[]> accounts;

    public Bank(int n, double initBalance) {
        accounts = ThreadLocal.withInitial(() -> {
            Double[] array = new Double[n];
            Arrays.fill(array, initBalance);
            return array;
        });
    }

    public void transfer(int from, int to, double amount) {
        if (accounts.get()[from] < amount) return;
        Double[] doubles = accounts.get();
        doubles[from] -= amount;
        String name = Thread.currentThread().getName();
        System.out.printf("[%s]- 账户%d向账户%d转账 %.2f元", name, from, to, amount);
        doubles[to] += amount;
        System.out.printf(" --- 当前银行总金额:%.2f %n", getTotal());
        accounts.set(doubles);
    }

    public double getTotal() {
        double sum = 0;
        for (double account : accounts.get()) {
            sum += account;
        }
        return sum;
    }

    public int size() {
        return accounts.get().length;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

注意:虽然看上去没有发生紊乱,但是!ThreadLocal 每次操作的都是共享变量的拷贝,相当于把银行复制了多份,每个银行都转账都是在自己的银行转账,转账记录不共享,并且无法将转账的结果回馈给原始的银行。

ThreadLocal 原理?
public class Thread implements Runnable {
    //......
    // 与此线程有关的ThreadLocal值。由ThreadLocal类维护
    ThreadLocal.ThreadLocalMap threadLocals = null;

    // 与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //......
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

从上面 Thread 类源代码可以看出 Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set 或 get 方法时才创建它们,实际上调用这两个方法的时候,我们调用的是 ThreadLocalMap 类对应的 get、set 方法。

ThreadLocal 类的 set 方法

public void set(T value) {
    // 获取当前请求的线程
    Thread t = Thread.currentThread();
    // 取出 Thread 类内部的 threadLocals 变量(哈希表结构)
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 将需要存储的值放入到这个哈希表中
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。

ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法。

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

弱引用介绍:

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

Atomic 原子类

所谓原子类说简单点就是具有原子/原子操作特征的类。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下。根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:

基本类型原子类

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean :布尔型原子类

上面三个类提供的方法几乎相同,所以我们这里以 AtomicInteger 为例子来介绍。

AtomicInteger 类常用方法

// 取当前的值
public final int get()
// 取当前的值,并设置新的值
public final int getAndSet(int newValue)
// 取当前的值,并自增
public final int getAndIncrement()
// 取当前的值,并自减
public final int getAndDecrement()
// 取当前的值,并加上预期的值
public final int getAndAdd(int delta)
// 如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
boolean compareAndSet(int expect, int update) 
// 最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
public final void lazySet(int newValue)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

AtomicInteger 类使用示例

public class AtomicIntegerTest {
    public static void main(String[] args) {
        int temvalue = 0;
        AtomicInteger i = new AtomicInteger(0);
        temvalue = i.getAndSet(3);
        System.out.println("temvalue:" + temvalue + ";  i:" + i); //temvalue:0;  i:3
        temvalue = i.getAndIncrement();
        System.out.println("temvalue:" + temvalue + ";  i:" + i); //temvalue:3;  i:4
        temvalue = i.getAndAdd(5);
        System.out.println("temvalue:" + temvalue + ";  i:" + i); //temvalue:4;  i:9
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

多线程环境使用原子类保证线程安全

class Test2 {
    private AtomicInteger count = new AtomicInteger();

    public void increment() {
        count.incrementAndGet();
    }
    
    public int getCount() {
        return count.get();
    }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

AtomicInteger 线程安全原理简单分析

// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

AtomicInteger 类主要利用 CAS + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

数组类型原子类

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray :引用类型数组原子类

上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerArray 为例子来介绍。

AtomicIntegerArray 类常用方法

// 获取 index=i 位置元素的值
public final int get(int i)
// 返回 index=i 位置的当前的值,并将其设置为新值:newValue
public final int getAndSet(int i, int newValue)
// 获取 index=i 位置元素的值,并让该位置的元素自增
public final int getAndIncrement(int i)
// 获取 index=i 位置元素的值,并让该位置的元素自减
public final int getAndDecrement(int i)
// 获取 index=i 位置元素的值,并加上预期的值
public final int getAndAdd(int i, int delta)
// 如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)
boolean compareAndSet(int i, int expect, int update) 
// 最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
public final void lazySet(int i, int newValue)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

AtomicIntegerArray 类使用示例

public class AtomicIntegerArrayTest {
    public static void main(String[] args) {
        int temvalue = 0;
        int[] nums = { 1, 2, 3, 4, 5, 6 };
        AtomicIntegerArray i = new AtomicIntegerArray(nums);
        for (int j = 0; j < nums.length; j++) {
            System.out.println(i.get(j));
        }
        temvalue = i.getAndSet(0, 2);
        System.out.println("temvalue:" + temvalue + ";  i:" + i);
        temvalue = i.getAndIncrement(0);
        System.out.println("temvalue:" + temvalue + ";  i:" + i);
        temvalue = i.getAndAdd(0, 5);
        System.out.println("temvalue:" + temvalue + ";  i:" + i);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
引用类型原子类

基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用引用类型原子类。

  • AtomicReference:引用类型原子类
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题
  • AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来

上面三个类提供的方法几乎相同,所以我们这里以 AtomicReference 为例子来介绍。

AtomicReference 类使用示例

public class HelloWorld {
    public static void main(String[] args) {
        AtomicReference<Person> ar = new AtomicReference<>();
        Person person = new Person("SnailClimb", 22);
        ar.set(person);
        Person updatePerson = new Person("Daisy", 20);
        ar.compareAndSet(person, updatePerson);

        System.out.println(ar.get().getName());
        System.out.println(ar.get().getAge());
    }
}
@Data
@AllArgsConstructor
@NoArgsConstructor
class Person {
    private String name;
    private int age;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

上述代码首先创建了一个 Person 对象,然后把 Person 对象设置进 AtomicReference 对象中,然后调用 compareAndSet 方法,该方法就是通过 CAS 操作设置 ar。如果 ar 的值为 person 的话,则将其设置为 updatePerson。实现原理与 AtomicInteger 类中的 compareAndSet 方法相同。

对象的属性修改类型原子类

如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。

  • AtomicIntegerFieldUpdater:原子更新整形字段的更新器
  • AtomicLongFieldUpdater:原子更新长整形字段的更新器
  • AtomicReferenceFieldUpdater :原子更新引用类型里的字段的更新器

要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 public volatile 修饰符。

上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerFieldUpdater为例子来介绍。

AtomicIntegerFieldUpdater 类使用示例

public class HelloWorld {
    public static void main(String[] args) {
        AtomicIntegerFieldUpdater<User> a = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");

        User user = new User("Java", 22);
        System.out.println(a.getAndIncrement(user));// 22
        System.out.println(a.get(user));// 23
    }
}

@Data
@AllArgsConstructor
@NoArgsConstructor
class User {
    private String name;
    public volatile int age;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

乐观锁与悲观锁

什么是悲观锁?使用场景是什么?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。

也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。像 Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

悲观锁通常多用于写多比较多的情况下(多写场景),避免频繁失败和重试影响性能。

什么是乐观锁?使用场景是什么?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

在 Java 中java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

乐观锁通常多于写比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。

如何实现乐观锁?

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。底层依赖于一条 CPU 的原子指令。

CAS 涉及到三个操作数:

  • V :要更新的变量值(Var)
  • E :预期值(Expected)
  • N :拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了V,则当前线程放弃更新。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。

乐观锁存在哪些问题?

ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

笔记大部分摘录自《Java核心技术卷I》,含有少数本人修改补充痕迹。

参考文章:http://985.so/mm0eh、https://javaguide.cn、http://985.so/mmfh9、http://985.so/m2qnf

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/404491
推荐阅读
相关标签
  

闽ICP备14008679号