Java 并发进阶知识之 synchronized 关键字

synchronized 相关知识

1、synchronized 简介

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

 synchronized 的以下几种最主要的使用方式:

(1)、同步一个代码块

public void func() {
    sychronized (this) {
        // ...
    }
}

它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。

对于以下代码,使用 ExcutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句时,另个线程就必须等待!

public class SynchronizedExample {
    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());// 用到了Lambda表达式
    executorService.execute(() -> e1.func1());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

对于以下代码,两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果看出,两个线程交叉。

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e2.func1());
}
0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9

(2)、同步一个方法

public synchronized void func () {
    // ...
}

它和同步代码块一样,作用于同一个对象,进入同步代码前要获得当前对象实例的锁。

(3)、同步一个类

public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。

public class SynchronizedExample {

    public void func2() {
        synchronized (SynchronizedExample.class) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func2());
    executorService.execute(() -> e2.func2());
}
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

(4)、同步一个静态方法

public synchronized static void fun() {
    // ...
}

作用于整个类,也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

综上:synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁synchronized 关键字加到静态方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

下面以一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。

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

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

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;
    }
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

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

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

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

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

2、synchronized 底层原理

synchronized 底层原理属于 JVM 层面。

① synchronized 同步代码块的情况

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 java Synchronized.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SychronizedDemo.class

从上面可以看出:

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处, JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 是所有权 (monitor 对象存在于每个 Java 对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因),即尝试获得对象的锁。虚拟机会在执行这两个指令的时候会检查对象的锁状态是否为空或当前线程是否已经拥有该对象锁,如果是,则将对象锁的计数器加 1,直接进入同步代码执行;如果不是当前线程就要阻塞等待,等到锁释放。

② synchronized 修饰方法的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

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

为了减少获得锁和释放锁所带来的性能消耗。JDK1.6 中出现了轻量级锁,偏向锁,锁消除,适应性自旋锁,锁粗化(自旋锁在 JDK1.4 时就有了,只不过默认是关闭的,JDK1.6 是默认开启的),这些操作是为了在线程之间更高效的共享数据,解决竞争问题,主要优化 synchronized 的获取锁和释放锁的性能问题。

3、JDK1.6 后 synchronized 底层的优化。

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

(1)自旋锁与适应性自旋锁

互斥同步对性能的最大影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能造成了很大的压力。其实在很多的应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。所以物理机器有一个以上的处理器,能让两个或以上线程同时并发执行,我们就可以让后面的线程“稍微等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是自旋。

自旋等待是不能代替阻塞的,自选等待本身虽然避免了线程切换的开销,但他是要占用处理器的时间的,因此,如果所被占用的时间很短,自旋等待的效果非常好,反之,如果所被占用的时间很长,呢么自旋的线程只会白白消耗处理器资源,而不作任何有用的工作,带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍没有成功获得锁,就应当使用传统的方式挂起线程了。

JDK1.6 引入了自适应自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

那自旋锁怎么实现?

如下代码(代码来源:https://blog.csdn.net/qq_34337272/article/details/81252853)

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}

lock() 方法利用的 CAS,当第一个线程 A 获取锁的时候,能够成功获取到,不会进入 while 循环,如果此时线程 A 没有释放锁,另一个线程 B 又来获取锁,此时由于不满足 CAS,所以就会进入 while 循环,不断判断是否满足 CAS,直到A线程调用 unlock 方法释放了该锁。

什么是 CAS ?

CAS 即 compare and swap (比较与替换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数:

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试

CAS 实现原子操作的三大问题:

  •  ABA 问题:因为 CAS 需要操作值的时候会检查值有没有发生变化,如果没有,则更新。但是如果一个值原来是 A,变成了 B,又变成了 A,那么在使用 CAS 进行检查会发现值没有发生变化,但实际上发生了变化。
  • 循环时间长开销大:自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
  • 只能保证一个共享源自的操作:对一个共享变量操作的时候,可以使用 CAS 的方式保证原子性,但是对于多个,CAS 无法保证,此时可以用锁。

(2)锁消除:

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其他线程访问到,那么久可以把它们当成私有数据对待,也就可以将他们的锁进行消除。

对于一些看起来没有加锁的代码,其实隐式的加了很多锁。如下:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个 StringBuffer.append() 方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

(3)锁粗化:

原则上,我们在编写代码的时候,总是推荐将同步代码块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

上一节的示例代码中连续的 append() 方法就属于这种情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

(4)轻量级锁:

以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在下面的 state 表格中给出。

下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。

对于没有竞争的多线程,轻量级锁使用 CAS 操作避免了使用互斥量的开销,如果存在锁竞争,处理互斥量的开销外,还会额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

(5)偏向锁:

偏向锁的思想是偏向于第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 “01”。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。

介绍一下 AbstractQueuedSynchronizer 

提供了一个基于 FIFO 队列,可以用于构建阻塞锁或者同步容器的基础框架,AQS 是基于 FIFO 队列的实现,因此必然存在一个个节点,Node 就是一个节点。对于 FIFO 队列的各种操作在 AQS 中已经实现了,AQS 的子类一般只需要重写 tryAcquire(int arg)tryRelease(int arg) 两个方法即可。成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。子类必须定义更改此状态的受保护方法,并定义哪种状态对于此对象以为这被获取或被释放。假定这些条件之后,此类中的其他地方就可以实现所有排队和阻塞机制。

tryAcquire(int arg) 试图在独占模式下获取对象状态。此方法应该查询是否允许它在独占模式下获取对象状态,如果允许,则获取它。可以用此方法来实现 Lock.tryLock() 方法。

acquire(int agr) 以独占模式获取对象,忽略中断。通过至少一次调用 tryAcquire(int arg) 来实现此方法,并在成功时返回。否则在成功之前,一直调用 tryAcquire(int arg) 将线程加入队列,线程可能重复被阻塞或不被阻塞。可以使用此方法来实现 Lock.lock() 方法。

tryRelease(int arg) 试图设置状态来反映独占模式下的一个释放。

release(int arg) 以独占模式释放对象。如果 tryRelease(int arg) 返回 true,则通过消除一个或多个线程的阻塞来实现此方法。可以使用此方法来实现 Lock.unlock() 方法。

tryAcquireShared(int arg) 试图在共享模式下获取对象状态。此方法应该查询是否允许它在共享模式下获取对象状态,如果允许则获取它。

tryReleaseShared(int arg) 试图设置状态来反映共享模式下的一个释放。

ReentrantLock 相关知识

1、ReentrantLock 简介

ReentrantLock 是 java.util.concurrent.locks 包中的可重入锁类。在高竞争条件下有更好的性能,且可以中断。ReentrantLock 是基于 AQS 实现的。AQS 是基于 FIFO 队列的实现,整个 AQS 是典型的模板模式的应用,对于 FIFO 队列的各种操作在 AQS 中已经实现, AQS 的子类一般只要重重写 tryAcquire(int arg) 和 tryRelease(int arg) 两个方法即可。

ReentrantLock 中有个抽象类 Sync 继承自 AbstractQueuedSynchronizer。ReentrantLock 根据传入的构造方法的布尔型参数实例化出 Sync 的实现类 FairSync 和 NonfiarSync,分别表示公平的 Sync 和非公平的 Sync。Sync,FairSync 和 NonSync 都是 ReentrantLock 的静态内部类。Sync 是一个抽象类,而 FairSync 和 NonFiarSync 则是具体类,分别对应了公平锁和非公平锁。由于 ReentrantLock 我们平时用的比较多的是非公平锁,所以看下非公平锁是如何实现的。假设单线程 1 调用了 ReentrantLock 的 lock() 方法,那么线程 1 将独占锁,整个调用链十分简单:

第一个获取锁的线程就做了两件事情:

(1)、设置 AbstractQueuedSynchronizer 的 state 为 1 (1 表示同步状态,0 表示未锁)

(2)、设置 AbstractOwnableSynchronizer 的 thread 为当前线程 (这个是 AQS 父类 AbstractOwnableSynchronizer 的属性,表示独占模式同步器的当前拥有者)。

final void lock(){
    if(compareAndSetState(0, 1){
        setExclusiveOwnerThread(Thread.currentThread());
    }else{
        acquire(1);
    }
}

(3)、当第二个线程想要获取锁时,会执行 else;调用 acquire 方法,进而调用 acquire 中的 tryAcquire 方法,若 tryAcquire 返回失败,则将其加入等待队列。

2、java 中 ReentrantLock 的使用场景

  • 需要使用可重入锁时,即当该子程序正在运行时,可以再次进入并执行它(并行执行时,个别的执行结果都符合设计时的预期)
  • 并发竞争很高的情况下
  • 需要使用可中断
  • 尝试等待执行:如果发现该操作以及在执行,则尝试等待一段时间,等待超时则不执行
  • 如果发现该操作以及在执行中则不再执行(有状态执行),例如:用在定时任务时,如果执行任务时间可能超过下次执行时间,确保该有状态任务只有一个正在执行,忽略重复触发。

3、ReentrantLock 与 synchronized 比较

(1)、锁的实现

synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。前面提到的 synchronized 的优化,这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成,见实例代码),所以可以通过查看源代码来看怎么实现的。

(2)、ReentrantLock 比 synchronized 增加了一些高级功能

相比 synchronized,ReentrantLock 增加了一些高级功能。主要有三点:① 等待可中断;② 可实现公平锁;③ 可现实选择性通知(锁可以绑定多个条件)

  • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ReentrantLock可以指定是公平锁还是非公平锁而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。
  • synchronized 关键字与 wait() 和 notify() / notifyAll() 方法相结合可以实现等待/通知机制,ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition() 方法。Condition 是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个 Lock 对象中可以创建多个 Condition 实例(即对象监视器),线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify() / notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现“选择性通知”,这个功能非常重要,而且是 Condition 接口默认提供的。而 synchronized 关键字就相当于整个Lock 对象中只有一个 Condition 实例,所有的线程都注册在它一个身上。如果执行 notifyAll() 方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而 Condition 实例的 signalAll() 方法只会唤醒注册在该 Condition 实例中的所有等待线程。

(3)、使用选择

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

4、ReentrantLock 与 synchronized 使用场景的比较

1)、Lock 可以使用 Condition 进行线程之间的调度

synchronized 则使用 Object 对象本身的 notify,wait,notifyAll 调度机制。

Condition 是 java5 以后出现的机制,有更好的灵活性,而且在一个对象里面可以有多个 Condition(即对象的监视器),则线程可以注册在不同的 Condition,从而可以有选择性的调度线程,更加灵活。 synchronized 就相当于整个对象只有一个单一的 Condition(即该对象本身),所有的线程都注册在它身上,线程调度的时候之后调度的所有注册线程,没有选择权,会出现相当大的问题。

2)、Lock 的锁定是通过代码实现的,而 synchronized 是在 JVM 层面实现。synchronized 在锁定时如果方法块跑出异常,JVM 会自动蒋所释放掉,不会因为出了异常没有释放锁造成线程死锁。但是 Lock 的话就享受不到 JVM 带来自动的功能,出现异常时必须在 finally 将锁释放掉,否则引起死锁。

3)、synchronized 的局限性:Lock 提供一种显示的,可轮询的定时的以及可中断的锁获取操作,synchronized 是无法中断一个正在等候获得锁的线程,使用 synchronized 的线程在等待锁时是不能响应中断的。

(轮询锁:当不能同时获得所有的锁时,可以使用轮询锁或者定时锁避免死锁。当一个线程需要获取多个锁时,已获得一部分锁,但是另一部分不可得,此时会返回失败,释放已获得锁,重新尝试获取所有的锁。)

总之:如果这个获取锁的线程由于要等待 IO 或者其他原因(比如调用 sleep 方法)被阻塞了,但是有没有释放锁,其他线程只能干巴巴的等着,试想一下,这多么影响程序执行效率。因此就需要一种机制可以不让等待的线程一直无限的等下去(比如只等待一定的时间或者能够响应中断),通过 Lock 就可以办到。再举个例子:当有多个线程读写文件时,读操作与写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作与读操作不会发生冲突现象。但是采用 synchronized 关键字来实现同步的话,就会出现一个问题:

如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,通过 Lock 就可以办到。另外,通过 Lock 可以知道线程之间有没有成功获得锁。这个是 synchronized 无法做到的。

volatile 关键字

如下代码(代码参考:https://www.cnblogs.com/xrq730/p/4853578.html):

public class MyThread28 extends Thread
{
    private boolean isRunning = true;

    public boolean isRunning()
    {
        return isRunning;
    }

    public void setRunning(boolean isRunning)
    {
        this.isRunning = isRunning;
    }
    
    public void run()
    {
        System.out.println("进入run了");
        while (isRunning == true){}
        System.out.println("线程被停止了");
    }
}
public static void main(String[] args)
{
    try
    {
        MyThread28 mt = new MyThread28();
        mt.start();
        Thread.sleep(1000);
        mt.setRunning(false);
        System.out.println("已赋值为false");
    }
    catch (InterruptedException e)
    {
        e.printStackTrace();
    }
}

运行结果:

进入run了
已赋值为false

也许这个结果有点奇怪,明明 isRunning 已经设置为 false 了, 线程还没停止呢?

这就要从 Java 内存模型(JMM)说起,这里先简单讲,虚拟机那块会详细讲的。根据 JMM,Java 中有一块主内存,不同的线程有自己的工作内存,同一个变量值在主内存中有一份,如果线程用到了这个变量的话,自己的工作内存中有一份一模一样的拷贝。每次进入线程从主内存中拿到变量值,每次执行完线程将变量从工作内存同步回主内存中。

出现打印结果现象的原因就是主内存和工作内存中数据的不同步造成的。因为执行 run() 方法的时候拿到一个主内存 isRunning 的拷贝,而设置 isRunning 是在 main 函数中做的,换句话说 ,设置的 isRunning 设置的是主内存中的 isRunning,更新了主内存的 isRunning,线程工作内存中的 isRunning 没有更新,当然一直死循环了,因为对于线程来说,它的isRunning依然是true。

解决这个问题很简单,给 isRunning 关键字加上 volatile。加上了 volatile 的意思是,每次读取 isRunning 的值的时候,都先从主内存中把 isRunning 同步到线程的工作内存中,再当前时刻最新的 isRunning。看一下给 isRunning 加了 volatile 关键字的运行效果:

进入run了
已赋值为false
线程被停止了

看到这下线程停止了,因为从主内存中读取了最新的 isRunning 值,线程工作内存中的 isRunning 变成了 false,自然 while 循环就结束了。

volatile 的作用就是这样,被 volatile 修饰的变量,保证了每次读取到的都是最新的那个值。线程安全围绕的是可见性原子性这两个特性展开的,volatile 解决的是变量在多个线程之间的可见性,但是无法保证原子性(注意:是无法保证原子性,并不是不能具有原子性,因为对任意单个的 volatile 变量的读/写具有原子性。类似于 volatile++ 这种复合操作才不具有原子性

多提一句,synchronized 除了保障了原子性外,其实也保障了可见性。因为 synchronized 无论是同步的方法还是同步的代码块,都会先把主内存的数据拷贝到工作内存中,同步代码块结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。

synchronized 关键字和 volatile 关键字的区别

  • volatile 是变量修饰符;synchronized 是修饰类、方法、代码段。
  • 多线程访问 volatile 关键字不会发生阻塞,而 synchronized 关键字可能会发生阻塞。
  • volatile 关键字能保证数据的可见性,但是不能保证数据的原子性;synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决在多线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ThreadLocal 相关知识

通常情况下,我们创建的变量是可以被任意线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?JDK 提供的 ThreadLocal 类正式为了解决这种问题的。ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存放每个线程的私有数据。

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

(可参考:https://www.cnblogs.com/xrq730/p/4854813.html)

简述 ThreadLocal 的原理

(https://www.cnblogs.com/dolphin0520/p/3920407.html)

首先,每个线程 Thread 内部都有一个 ThreadLocal.ThreadLcoalMap 类型的成员变量 threadLocals,ThreadLocalMap 是 ThreadLocal 的内部类,这个 threadLocals 就是用来存储实际的变量副本,键值为当前 ThreadLocal 变量,value 为变量副本(即 T 类型的变量)。

初始时,在 Thread 里面,threadLocals 为空,当通过 ThreadLocal 变量调用 get() 方法或 set() 方法,就会对 Thread 类中的 threadLocals 进行初始化,并且以当前 ThreadLocal 变量为键值,以 ThreadLocal 要保存的副本变量为 value,存到 threadLocals 里。

然后在当前线程里面,如果要使用副本变量,就可以通过 get 方法在 threadLocals 里面查找

最常见的 ThreadLocal 使用场景是用来解决数据库连接、session 管理等。

由于请求中的一个事务涉及多个 DAO 操作,而这些 DAO 中的 Connection 不能从连接池中获得,如果是从连接池获得的话,两个 DAO 就用到了两个 Connection,这样的话是没有办法完成一个事务的。DAO 中的 Connection 如果是从 ThreadLocal 中获得 Connection 的话,那么这些 DAO 就会被纳入到同一个 Connection 之下。

一个材料人跨行到互联网的研究僧

希望大家能多多关注~

作者:意无尽 公众号:意无尽 关于作者:本人目前传统专业,现自学 Java,后续会有向大数据方向转型。希望自己能一步一个脚印的走下去,以此博客来见证我技术的成长轨迹!
原文地址:https://www.cnblogs.com/reformdai/p/11045404.html