Java并发编程实战之 线程安全性、对象的共享、对象的组合

线程安全性当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式修复这个问题:不在线程之间共享该状态变量将状态变量修改为不可变类型在访问状态变量时使用同步内置锁Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。synchroized (lock) { // 访问或修改由锁保护的共享状态}每个 Java 对象都可以用做一
摘要由CSDN通过智能技术生成

线程安全性

当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式修复这个问题:

  • 不在线程之间共享该状态变量
  • 将状态变量修改为不可变类型
  • 在访问状态变量时使用同步

内置锁

Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。

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

每个 Java 对象都可以用做一个实现同步的锁,这些锁被称为 内置锁(Intrinsic Lock)监视器锁(Monitor Lock)

线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法

Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁,当线程A尝试获取一个由线程B持有的锁,线程A必须等待或者阻塞,知道线程B释放这个锁。如果B不能释放,造成死锁,那么A将永远地等待下去。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会互相干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义——一组语句作为一个不可分割的单元被执行

重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。

“重入"意味着获取锁的操作粒度是"线程”,而不是"调用"。

重入的一种实现方法是为每个锁关联一个计数器和一个所有者线程。当计数器为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数器将递增,而当线程退出同步代码块时,计数器会相应的递减。当计数器为0,这个锁将释放。

例如:

public class Widget {
   
    public synchronized void doSomething() {
   
        ...
    }
}

public class LoggingWidget extends Widget {
   
    public synchronized void doSomething() {
   
        System.out.println(toString() + ": calling doSomething");
        super.doSomething();
    }
}

由于 Widget 和LoggingWidget 中 doSomething 方法都是 synchronized 方法,因此每个 doSomething 方法在执行前都会获取 Widget 上的锁。然而,如果内置锁是不可重入的,那么在调用 super.doSomething 方法时将无法获得 Widget 上的锁。

super.doSomething()的含义是,通过super引用调用从父类继承而来的doSomething()方法,那么锁的还是当前的子类对象,因此子类对象被锁了2次,说明内置锁是可重入的,否则会发生死锁。

用锁来保护状态

由于锁能使其保护的代码路径以串行(多个线程一次以独占的方式访问)来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

访问共享状态的符合操作,例如计数器的递增操作(读取-修改-写入)或者延迟初始化(先检查-后执行),都必须是原子操作以避免产生竞态条件。

**如果在符合操作的执行过程中持有一个锁,那么回时复合操作成为原子操作。**然而,仅仅将复合操作封装到一个同步代码块是不够的。

如果使用同步来协调对某个变量的访问,那么在访问这个变量的所有位置都需要使用同步。而且,当使用锁来协调对某个变量的范文时,在访问变量的所有位置都要使用同一个锁。

一种常见的错误认为只有在写入共享变量时才需要使用同步,但事实并非如此(下一节会提到)

对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。

当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。

每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

并非所有数据都需要锁的保护,只有被多个线程同时访问的可变类型数据才需要通过锁来保护。当某个变量由锁来保护时,意味着每次访问这个变量都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。

对象的共享

可见性

非原子的 64 位操作

当线程在没有同步的情况下读取变量时,可能会得到一个失效值(非最新),但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-air safety)

最低安全性适用于绝大多数变量,但存在一个例外:非 volatile 类型的 64 位数值变量(long 和 double)——long 和 double 的非原子协定

**Java 内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非 volatile 类型的 long 和 double 变量,JVM 允许将 64 位的读操作或写操作分解为两个 32 位的操作。**当读取一个非 volatile 类型的 long 变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。

因此,即使不考虑是小数据问题,在多线程中使用共享且可变的 long 和 double 等类型的变量也是不安全的,除非使用 volatile 来声明它们,或者用锁保护起来

加锁与可见性

内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。如下图所示。当线程A执行某个同步代码块时,线程B随后进入一个由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A 看到的变量值在 B 获得锁后同样可以由 B看到。

img

**之所以要在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是因为要确保某个线程写入该变量的值对于其他线程来说都是可见的。**否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的就可能是一个失效值。

Volatile 变量

Java 提供了一种稍弱的同步机制——即 volatile 变量,用来确保将变量的更新操作通知到其他线程。把变量声明为 volatile 类型之后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。

虽然 volatile 变量很方便,但也存在一些局限性。volatile 变量通常用作某个操作完成、发生中断或者表示状态的标志。尽管 volatile 可以表示状态等其他信息,但在使用时要非常小心。volatile 的语义不足以确保递增操作 (count++) 的原子性,除非你能确保只有一个线程对变量执行写操作。

加锁机制既能确保可见性又能确保原子性,而 volatile 变量只能确保可见性

当且仅当满足以下所有条件时,才应该使用 volatile 变量:

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

发布与溢出

发布(Publish)” 一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如:

  • 将一个指向该对象的引用保存到其他代码可以访问的地方
  • 或者在某一个非私有的方法中返回该引用
  • 又或者将引用传递到其他类的方法中

当某个不应该发布的对象被发布时,这种情况就被称为 “**溢出 (Escape) **”。

发布一个对象

public static Set<Sercret> knownSercets;

public void initialize() {
   
    knowSercets = new HashSet<Sercet>();
}

当某个对象发布时,可能会间接发布其他对象,就比如例子中的 knownSercets 对象,因为任何代码都可以遍历这个集合。

线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。这种技术被称为 线程封闭(Thread Confinement)

例如一种常见的应用是 JDBC(Java Database Connectivity)的 Connection 对象。JDBC 规范并不要求 Connection 对象是线程安全的。

Ad-hoc 线程封闭

Ad-hoc 线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。它是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。

当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在程序中尽量少使用。

栈封闭

在栈封闭中,只能通过局部变量才能访问变量

栈封闭(也被称为线程内部使用或者线程局部使用)比Ad-hoc线程封闭更易于维护,也更加健壮。

Java语言确保了基本类型的局部变量始终封闭在线程内。下面代码是表示:基本类型的局部变量与引用变量的线程封闭性。

public int loadTheArk(Collection<Animal> candidates) {
   
		SortedSet<Animal> animals;
		int numPairs = 0;
		Animal candidate = null;
		
		// animals被封装在方法中,不要使它们溢出!!
		animals = new TreeSet<Animal>(new SpeciesGenderComparator());
		animals.addAll(candidates);
		for(Animal a:animals){
   
			if(candidate==null || !candidate.isPotentialMate(a)){
   
				candidate = a;
			}else{
   
				ark.load(new AnimalPair(candidate,a));
				++numPairs;
				candidate = null;
			}
		}
		return numPairs;
}

在 loadTheArk 中实例化一个TreeSet对象,并将该对象的一个引用保存到animals中。此时,只有一个引用指向集合animals,这个引用被封闭到局部变量中,因此也被封闭到局部变量中。

然而,如果发布了对集合animals(或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对象animals的逸出。

ThreadLocal 类

维持线程封闭性的一种更规范方法是使用 ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。

ThreadLocal 提供了 get 与 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。

ThreadLocal 对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。

例如通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。

private static ThreadLocal<Conection> connectionHolder = new ThreadLocal<Connection>(){
   
	public Connection initialValue(){
   
		return DriverManager.getConneciton(DB_URL);
	}
}
	
public static Connection getConnection(){
   
	return connectionHolder.get();
}

当某个频

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值