2020-02-01 1. Java语言提供的基本线程安全保护

本文是Java线程安全和并发编程知识总结的一部分。

1 java语言提供的基本线程安全保护

本章节总结java语言本身自带的线程安全相关的知识点。

1.1 什么时候需要考虑线程安全

并不是所有情况都需要考虑线程安全,或者说,有些场景天生就是线程安全的,不需要我们做额外的工作。

1.1.1 方法栈保护下的线程安全。

前提:

  1. 类中的一个方法,没有返回对象(因此不会将对象发布出去),或返回不可变对象
  2. 方法参数不是对象,或是不可变对象
  3. 方法体中的代码没有访问类/对象的非常量成员变量(在讨论线程安全时,一般称为状态),或只访问常量成员变量,也没有访问其他类的成员变量。

线程安全的原因:

  1. 由于每个线程调用一个方法时,都会拥有自己的方法栈;该方法内的局部变量只存在于线程自己的方法栈中,因此不会在多线程之间共享,从而受到方法栈的线程安全保护。
  2. 方法不访问类/对象成员变量,或只访问常量成员变量,因此不存在多线程之间的资源竟态,不会线程不安全。
  3. 方法不和外界通过对象(本质是引用)共享数据,或线程外界共享的对象都是不可变对象,因此不存在资源竟态,也是安全的。

举例

  1. 最简单的方法栈提供的线程安全
    该方法输入参数没有对象,都是基本类型;返回值也是基本类型;且不使用类/对象成员变量,也不访问其他类的成员。它受到方法栈保护,天生是线程安全的。
/**
 * @author xxx
 * 2020年1月31日 上午11:30:18
 */
public class Sample1 {
    
    /**
     * 该方法由方法栈提供线程安全保护
     * 2020年1月31日 上午11:30:51 xxx 添加此方法
     * @param a 参与计算的基础类型参数
     * @param b 参与计算的基础类型参数
     * @return 返回的也是基础类型
     */
    public int  calc1(int a, int b) {
        int result = a * 10 + b;
        result++;
        
        return result;
    }
}
  1. 输入参数或返回值使用不可变对象

该方法输入参数接收不可变对象或基本类型,返回值也是一个不可变对象;且方法内代码不访问类/对象的成员变量,也不访问其他类的成员。它受到方法栈保护,天生线程安全。

/**
 * @author xx
 * 2020年1月31日 下午2:39:31
 */
public class Sample2 {
    
    /**
     * 接受不可变对象和基本类型作为输入参数,返回不可变对象的方法,收到方法栈的线程安全保护。
     * 2020年1月31日 下午2:50:17 xx添加此方法
     * @param laltitude
     * @param longitude
     * @return
     */
    public ImmutablePoint doSome2dCalc(ImmutablePoint point, double laltitude, double longitude) {
        // 对坐标做一系列业务处理,得到新坐标。
        double newLatitude = point.getLatitude();
        double newLongitude = point.getLongitude();
        
        // 返回一个不可变对象作为输出参数。
        return new ImmutablePoint(newLatitude, newLongitude);
    }
}

/**
 * 不可变的点对象
 * @author xx
 * 2020年1月31日 下午2:41:48
 */
public class ImmutablePoint {
    
    /**
     * 构造函数
     */
    public ImmutablePoint(double latitude, double longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }
    
    /**
     * 维度
     */
    private double latitude;
    
    /**
     * 经度
     */
    private double longitude;
    
    /**
     * 获取属性  latitude 的值
     * @return 属性 latitude 的值
     */
    public double getLatitude() {
        return this.latitude;
    }
    
    /**
     * 获取属性  longitude 的值
     * @return 属性 longitude 的值
     */
    public double getLongitude() {
        return this.longitude;
    }
}
  1. 访问类的常量成员
    当一个方法只访问所在类的常量成员变量时,显然该常量成员变量不可能形成竟态条件,因此是天生线程安全的。
/**
 * @author xx
 * 2020年1月31日 下午3:04:19
 */
public class Sample3 {
    
    /**
     * 常量成员
     */
    private static final int Dummy_State = 1;
    
    /**
     * 只访问常量成员,受到方法栈的线程安全保护。
     * 2020年1月31日 下午2:50:17 xx添加此方法
     * @param a 基本类型参数
     * @param b 基本类型参数
     * @return
     */
    public int calc(int a, int b) {
        // 使用常量成员参与计算
        int result = this.doCalc(Sample3.Dummy_State, a, b);
        
        return result;
    }
    
    private int doCalc(int factor, int a, int b) {
        int result = 0;
        
        // 实际的计算逻辑
        
        return result;
    }
}

1.1.2 由调用者确保被调用方法不会被多线程访问

任何线程安全代码,都会增加代码复杂度。因为编程语言本身,实际上是基于串行的。因此,当非常明确某个业务方法不会被用于多线程访问时,就无需进行线程安全保护处理。
典型的场景包括:
1. 供不支持并发执行的定时器调用的方法
比如,定时器框架quartz允许将定时器配置为支持并行执行或不支持并行执行。
当配置为不支持并行执行时,如果一个定时器的前一次执行尚未结束,下一次执行的时间又到了的话,下一次执行将被阻塞,直到前一次执行结束才启动执行。

2. 只执行一次的初始化逻辑
比如由spring的@PostConstruct注解的单例bean上的方法:

/**
 * @author xx
 * 2020年1月31日 下午3:48:15
 */
@Service
public class Sample4 {
    
    /**
     * 只在一个线程中执行一次
     * 2020年1月31日 下午3:49:12 xx添加此方法
     */
    @PostConstruct
    public void init() {
        // 在独立线程中执行,避免影响spring容器的初始化。
        new Thread(() -> {
            this.loadCacheFromDb();
        }).start();
    }

    /**
     * 本方法不提供线程安全保护,由调用者确保该方法只被一个线程调用。
     * 2020年1月31日 下午3:55:48 xx添加此方法
     */
    private void loadCacheFromDb() {
        // 初始化逻辑,比如从数据库中加载需要缓存的数据等
    }
}

3. 类的静态初始化代码块。
类的静态初始化块,是在类被jvm初始化时调用的,由虚拟机的内部同步机制确保其线程安全。

/**
 * @author xx
 * 2020年1月31日 下午4:00:01
 */
public class Sample5 {
    
    /**
     * 一个字符串键值对缓存容器。
     */
    private static Map caches;
    
    /**
     * 静态初始化块,由jvm内部同步机制确保其线程安全。
     */
    static {
        caches = new HashMap<>(10);
        
        for (int i = 0; i < 10; i++) {
            caches.put(Integer.toString(i), "some value " + i);
        }
    }
}

1.1.3 由调用者提供线程安全保护

即某个方法本身并未提供线程安全保护,且形成了竟态条件,是线程不安全的。但是在业务上可以确保该方法总是被另外一个方法调用,且调用该方法的代码块有线程安全保护。那么,这个方法实际上构成了事实线程安全。
比如:

/**
 * @author xx
 * 2020年1月31日 下午4:08:45
 */
public class Sample6 {
    
    /**
     * 表示当前类状态的成员变量
     */
    private int state;
    
    /**
     * 只访问常量成员,受到方法栈的线程安全保护。
     * 2020年1月31日 下午2:50:17 xx添加此方法
     * @param a 基本类型参数
     * @param b 基本类型参数
     * @return
     */
    public int calc(int a, int b) {
        int result = 0;
        
        // 通过内部锁确保只有一个线程调用这段代码。
        // doCalc方法的调用者提供了线程安全保护,是线程安全的。
        synchronized (this) {
            // 先执行一些逻辑
            
            result = this.doCalc(a, b);
            
            // 再执行一些逻辑
        }
        
        return result;
    }
    
    /**
     * 该方法本身不提供线程安全保护,本身不具备线程安全,由其调用者提供保证。
     * 2020年1月31日 下午4:09:53 xx添加此方法
     * @param a 基础类型变量
     * @param b 基础类型变量
     * @return
     */
    private int doCalc(int a, int b) {
        int result = 0;
        
        // 内部成员变量加入计算,形成竟态条件,存在线程安全问题
        if (this.state < 0) {
            this.state++;
            
            // 执行计算逻辑1,并将结果赋值给 result
        } else if (this.state == 0) {
            // 执行计算逻辑2,并将结果赋值给 result
        } else {
            this.state--;
            
            // 执行计算逻辑3,并将结果赋值给 result
        }
        
        return result;
    }
}

这类场景往往重度依赖相关代码充分注释,或详细的文档说明,以及开发团队的管理。很容易因为人为因素导致线程安全被破坏。

1.2 使用java语言的内置锁机制(synchronized语法)

Java语法的synchronized关键词,提供了使用Java提供的内置可重入锁的机会。Jvm为每个对象默认提供了一个可重入锁;无论你是否使用,该锁都存在,因此称为内置锁。

它有两种用法:
1. 用于方法
该关键词用于方法时,若该方法被调用,进入该方法时会自动尝试获得当前对象的内置锁,如果获取内置锁失败,则当前线程阻塞;退出该方法时会自动释放当前对象的内置锁。

由于是使用方法所在对象的内置锁,因此任意时候,只有一个线程可以执行该方法,其他调用该方法的线程都会被阻塞。

/**
 * @author xx
 * 2020年1月31日 下午4:08:45
 */
public class Sample7 {
    
    /**
     * 表示当前类状态的成员变量
     */
    private int state;
    
    /**
     * 2020年1月31日 下午4:38:11 xx 添加此方法
     * @param args
     */
    public void startCalc() {
        // 虽然启动了5个线程,但由于内部锁的存在,实际上任意时刻都只有一个线程在执行,其他线程都被阻塞了
        for (int i = 0; i < 5; i++) {
            int times = i;
            int a = 5 + i;
            int b = 7 + i;
            new Thread(() -> {
                int result = this.calc(a, b);
                System.out.println(" i = " + times + ", result = " + result);
            }).start();
        }
    }
    
    /**
     * 由 synchronized 提供线程安全保护
     * 2020年1月31日 下午2:50:17 xx添加此方法
     * @param a 基本类型参数
     * @param b 基本类型参数
     * @return
     */
    private synchronized int calc(int a, int b) {
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        int result = 0;
        
        // 先执行一些逻辑

        // 内部成员变量加入计算,形成竟态条件,存在线程安全问题
        if (this.state < 0) {
            this.state++;
            
            // 执行计算逻辑1,并将结果赋值给 result
        } else if (this.state == 0) {
            this.state -= 2;
            // 执行计算逻辑2,并将结果赋值给 result
        } else {
            this.state--;
            
            // 执行计算逻辑3,并将结果赋值给 result
        }
        
        // 再执行一些逻辑
        
        return result;
    }
}

2. 用于代码块
该关键词用于代码块时,必须用一个对象做锁。我们可以使用任意对象来作为 synchronized 关键词的锁;其实质,是使用给定对象的内部锁作为锁。
比如:
synchronized(this) ,实际上是使用 this 对象的内部锁做锁。比如 Sample6。
synchronized(someObj),实际上是使用对象 someObj 的内部锁做锁。

3. 注意

  • synchronized 语义实现的是不可中断锁。

也就是说,当前进入同步代码块以后,及时代码跑出 InterruptException,代码也不会中断,因为不响应中断异常。具体参看2.2.1 可重入锁与不可重入锁,其中详细举例说明。

  • 当使用字符串做锁时,因为jvm字符串常量池的存在,要使用 string.intern() 做锁,不要直接用字符串。

因为jvm字符串常量池的存在,为了避免两个字面值相同的字符串实际上指向不同的对象,要使用 string.intern() 做锁,不要直接用字符串。string.intern()方法返回相同字符串在字符串常量池中的对象引用,确保了对相同字符串内容,得到的是同一个对象。

1.3 使用Java语言的volatile机制提供的弱同步保护

这个关键词的作用有两个:

  1. 是告诉虚拟机,不要将该关键词修饰的类成员变量,存放在寄存器或其他处理器不可见的地方。这样,一旦该成员变量被修改,能够立即被所有线程看到。也就是能提供同步的可见性;但它不提供同步的原子性,因此是不完全的同步保护,是弱同步保护。
  2. 在方法中访问该关键词修饰的成员变量时,不要参与重排序。关于jvm重排序,不了解的自行百度。

其优点在于:

比同步效率高,当没有复合原子操作时,可以用该关键词提供可见性保护。

其缺点在于:

正如其优点所言,它只保证可见性保护,不确保原子性保护。

显然,该关键词不能用于有多个相关状态属性的场景,此时必然出现竟态条件,有原子性保护需求。
事实上,当需要该关键词的场景,都可以使用java.util.concurrent.atomic包提供的原子量来替代。

你可能感兴趣的:(2020-02-01 1. Java语言提供的基本线程安全保护)