【Java基础知识 17】聊一聊同步代码块

2年前 (2022) 程序员胖胖胖虎阿
203 0 0

【Java基础知识 17】聊一聊同步代码块

目录

    • 一、什么是内置锁?
    • 二、什么是重入?
    • 三、活跃性与性能
    • 四、对象的共享
      • 1、可见性
      • 2、非原子的64位操作
      • 3、volatile变量

一、什么是内置锁?

Java提供了一种内置的锁机制来支持原子性:同步代码块。
同步代码块包含两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

synchronized(lock){
	//访问或修改由锁保护的共享状态
}

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁。线程在进入同步代码块之前会自动获取锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁相当于一个互斥体,这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞。直到线程B释放这个锁。如果线程B永远不释放锁,那么A也将永远等待下去。
由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义,一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其它线程正在执行由同一个锁保护的同步代码块。

二、什么是重入?

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。重入意味着获取锁的操作的粒度是线程,而不是调用。重入的一种实现方法是为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会响应地递减。当计数值为0时,这个锁将被释放。

package com.guor.util;

public class Father {
    public synchronized void say(){
        System.out.println("我是父类");
    }
}
package com.guor.util;

public class Son extends Father {
    public synchronized void say(){
        System.out.println(toString()+"call say()");
        super.say();
    }

    public static void main(String[] args) {
        Son son = new Son();
        son.say();
    }
}

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在上面代码中,子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于Father和Son中say方法都是synchronized方法,因此每个say方法在执行前都会获取Father上的锁。然而,如果内置锁不是可重入的,那么在调用super.say时将无法获得Father上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。

三、活跃性与性能

要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其它线程可以访问共享状态。
在获取与释放锁的操作上都需要一定的开销,因此如果将同步代码块分解得过细,那么通常并不好,尽管这样做不会破坏原子性。当访问状态变量或者在复合操作的执行期间,需要持有锁,但在执行时间较长的因数分解运算之前要释放锁。这样既确保了线程安全性,也不会过多地影响并发性,并且在每个同步代码块中的代码路径都“足够短”。
要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性、简单性、性能。
当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间,无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能的问题。
当执行时间较长的计算或者可能无法快速完成的操作时(例如网络IO或控制台IO),一定不要持有锁。

四、对象的共享

1、可见性

可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。在单线程环境中,如果向某个变量写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。这听起来很自然。然而,当读操作和写操作在不同的线程中执行时,情况却并非如此,这听起来或许有些难以接受。通常,我们无法保证执行读操作的线程能实时地看到其它线程写入的值,优势甚至是不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

2、非原子的64位操作

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。
最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的longdouble变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的longdouble类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。

3、volatile变量

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其它内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其它处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,读取volatile变量就相当于进入同步代码块。然而,我们并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更加脆弱,也更难以理解。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当满足以下所有条件时,才应该使用volatile变量。

  1. 当变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  2. 该变量不会与其它状态变量一起纳入不变性条件中。
  3. 在访问变量时不需要加锁。

上一篇:Java学习路线总结,搬砖工逆袭Java架构师

下一篇:Java基础教程系列

版权声明:程序员胖胖胖虎阿 发表于 2022年11月3日 上午2:16。
转载请注明:【Java基础知识 17】聊一聊同步代码块 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...