线程安全及其策略

1 线程安全

之前介绍了线程之间的交错与竞争,线程之间的竞争条件是作用于同一个mutable数据上的多个线程,彼此之间存在对该数据的访问竞争并导致interleaving,导致post-condition可能被违反,这就导致了线程不安全。

线程安全:ADT或方法在多线程中要执行正确,不需要额外的协调。共有四种线程安全的策略,分别为限制数据共享(Confinement),共享不可变数据(Immutability),共享线程安全的可变数据(Thread data type)和同步机制(Synchronization):通过所得机制共享线程不安全的可变数据,变并行为串行。

1.1 Confinement

限制数据共享。核心思想:线程之间不共享mutable数据类型,包括变量和类。将可变数据限制在单一线程内部,避免竞争,不允许任何线程直接读写该数据,而且对象引用也必须是受限的。

因此,当线程中出现静态类或静态变量时,尽可能将其变为局部私有变量,否则,必须保证只有一个线程可以使用它,或者在客户端做出声明和限制。
Ex:

// This class has a race condition in it.
public class PinballSimulator {

    private static PinballSimulator simulator = null;
    // invariant: there should never be more than one PinballSimulator
    //            object created

    private PinballSimulator() {
        System.out.println("created a PinballSimulator object");
    }

    // factory method that returns the sole PinballSimulator object,
    // creating it if it doesn't exist
    public static PinballSimulator getInstance() {
        if (simulator == null) {//(1)
            simulator = new PinballSimulator();//(2)
        }
        return simulator;//(3)
    }
}

上面这个例子中的getInstance方法是static,可以被多个线程所调用,可以假设有两个线程同时调用该静态方法,考虑第一个线程调用(1)处语句,第二个线程调用(3)处语句,尽管第二个线程已经new一个新的类,但是第一个线程仍然可能判断sumulator为空,因为Java不能保证线程之间读取已分配是瞬时的,读取缓存同样需要时间。
但是,如果一个ADT的rep中包含mutable的属性且多线程之间对其进行mutaor操作,那么就很难使用该策略来保证该ADT是线程安全的。

1.2 Immutability

Immutable数据通常是线程安全的,因此可以使用不可变数据类型和不可变引用,避免多线程之间的race condtion。
给出Immutability更广泛的定义:

  • 没有mutaor的方法;
  • 所有的fields是private,final;
  • 没有representation exposure
  • 没有突变,也不允许beneficent mutation,即使这些改变对用户可见

但是有时候却一定需要mutable类型的ADT,所以该方法在实用性上也存在一定问题

1.3 Using Threadsafe Data Types

如果必须要使用mutable的数据类型在多线程之间共享数据,要使用线程安全的数据类型。在JDK的类中,文档中明确指明哪些数据类型是threadsafe,但是一般线程安全的类性能上受影响。一个比较常见的例子就是StringBuffer和StringBuilder,在之前的实验中也有使用,这都是mutable类型,在单线程使用是一样的,不同的是StringBuffer是线程安全的,而StringBuilder不是。

集合类List,Set,Map等都是线程不安全的,不能用于多线程,Java API提供了进一步的decorator,这也就是装饰模式的一个应用。包装器将其所有的工作委托给指定的集合,在该集合提供的功能的基础上添加额外的功能。经过包装后的方法,使得Collection线程安全,对于包装后的Collection的每个操作调用,都是以原子形式执行,线程在运行期间,不会与其他操作交错。

private static Map<Integer,Boolean> cache=Collections.synchronizedMap(new HashMap<>());

需要注意:

  • 不要绕过包装。即使对集合类进行了包装,其底层的集合仍然是可变的,引用它的代码可以绕过不变性,尽量避免进行底层引用。所以在使用synchronizedMap(new HashMap<>())之后,不要抱参数hashMap共享给其他线程,也不能保留别名。
  • 迭代器(Itertors)不是线程安全的。即使在线程安全的集合类上,使用iterator也是不安全的,除非使用lock机制。
  • 原子操作不足以避免竞争。当将多个原子操作放在一起时,仍有可能引起竞争,如
if(!list.isEmpty()){
	String s=list.get(0);
	...
}

1.4 Locks and Synchronization

先总结一下前三种策略的核心思想:

  • 避免共享;即使共享,也只能读/不可写(immutable);即使可写(imutable),共享的可写数据应自己具备在多线程之间的协调的能力。
  • 各自缺陷:不能全局rep共享数据;只能读共享数据;可以共享“读写”,但是只有单一方法安全,多个方法调用不安全

第四种策略是同步与锁,程序员负责多线程之间对mutable数据的共享操作,防止多线程同时访问共享数据。这里使用了一种锁的同步技术。使用锁机制,可以获得数据的独家mutation权,锁告诉编译器和处理器正在并发地使用共享内存,这样寄存器和缓存被刷新到共享存储中,从而确保所得所有者总是在查看最新数据,在这过程中其他线程被阻塞,不得访问。阻塞意味着线程等待,一直到一个打破这份等待。
关于Lock的两个操作:

  • acquire,允许线程获得锁所有权。如果一个线程试图获取另一个线程当前拥有的锁,它将被阻塞,直到另一个线程释放该锁,同一时间只有一个线程拥有锁。
  • release,释放意味着锁的拥有者放弃锁的拥有权
1.4.1 Locking

Lock是Java提供的一个内嵌机制,每个object都有相关联的lock,但是不能再Java的内部锁上调用acquire和release操作,可以使用synchronized语句获取语句块期间的锁。

Object lock =new Object();

synchronized (lock) { // thread blocks here until lock is free
    // now this thread has the lock
    balance = balance + 1;
    // exiting the block releases the lock
}

拥有lock的线程可以独占式地执行该部分代码,而其他线程只能等待,这是一种“互斥”的行为。

Lock保护共享数据的访问。如果所有存取某数据变量被同一个锁锁定,则其存取就成为了原子型操作,不会被其他线程干扰。

在编写类的方法时,最方便的锁是对象实例本身,即this,用ADT自身做lock,也就是使用synchrinized(this)

/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
    private String text;
    ...
    public SimpleBuffer() {
        synchronized (this) {
            text = "";
            checkRep();
        }
    }
    ...
    public String toString() {
        synchronized (this) {
            return text;
        }
    }
}

由此产生一个Monitor pattern(监视模式):所有调用rep的方法都用同一个锁,即ADT本身,使得ADT的方法是互斥访问的。

除此之外,还有一个和synchronized(this)起相同作用的方法,可以添加synchronized到方法的描述中,Java把这个方法整体锁定

public class SimpleBuffer implements EditBuffer {
    private String text;
    ...
    public SimpleBuffer() {//构造方法无需添加synchronized关键字
         text = "";
         checkRep();
    }
    ...
    public synchronized String toString() {
         return text;
    }
}
1.4.2 Java内置锁三种使用方式

内置锁使用比较方便,不需要显示地获取和释放,任何一个对象都能作为一把内置锁,意味着出现synchronized关键字的地方,都有一个对象与之关联。

  • 当synchronized作用于普通方法时,锁对象是this
  • 当synchronized作用于静态方法时,锁对象是当前类的Class对象
  • 当synchronized作用于代码块时,锁对象是synchronized(obj)中的obj
public synchronized void add(int t){//同步方法
	this.v+=t;
}

public static synchronized void sub(int t){//同步静态方法
	value-=t;
}

public int decrementAndGet{
	synchronized(obj){//同步代码块
		return --v;
	}
}

1.4.3 Deadlock

使用锁的机制可以解开竞争条件,但同时锁又带来一个问题,因为使用锁需要线程等待,所有有可能进入两个线程相互等待的情况,因此两个线程都无法取进展。这就是死锁的情况:多个线程竞争lock,相互等待对方释放lock,这样就会进入一个依赖循环。
EX:一个用户类,rep包括姓名和其朋友的Set集合,包括添加朋友,删除朋友,查找朋友三个方法,这些方法都使用synchronized关键词修饰。

public class Wizard {
    private final String name;
    private final Set<Wizard> friends;
    // Rep invariant:
    //    name, friends != null
    //    friend links are bidirectional: 
    //        for all f in friends, f.friends contains this
    // Concurrency argument:
    //    threadsafe by monitor pattern: all accesses to rep 
    //    are guarded by this object's lock

    public Wizard(String name) {
        this.name = name;
        this.friends = new HashSet<Wizard>();
    }

    public synchronized boolean isFriendsWith(Wizard that) {
        return this.friends.contains(that);
    }

    public synchronized void friend(Wizard that) {
        if (friends.add(that)) {
            that.friend(this);
        } 
    }

    public synchronized void defriend(Wizard that) {
        if (friends.remove(that)) {
            that.defriend(this);
        } 
    }
}

假设有两个线程在调用Wizard类中friend方法和defriend方法。

//two users
Wizard harry = new Wizard("Harry Potter");
Wizard snape = new Wizard("Severus Snape");

//Thread A
harry.friend(snope);
harrt.defriend(snape);

//Thread B
snape.friend(harry);
snape.defriend(harry);

当线程A要触发harry.friend(snope),而线程B要触发harry.friend(snope)时,线程A需要harry的锁,而线程B需要snope的锁,都在互相等待对方,因此都无法执行,这就是死锁的一个简单例子。

对于死锁的情况有几种解决的方法:

  • 使用粗粒锁。多个类用一个锁,但会造成比较严重的性能损失
  • 锁排序。对需要同时获取的锁进行排序,并确保所有的代码都按照该顺序获取锁
    比如对于上述例子的friend函数,可以根据wizard类名字的字母表的顺序获取锁
public void friend(Wizard that) {
        Wizard first, second;
        if (this.name.compareTo(that.name) < 0) {
            first = this; second = that;
        } else {
            first = that; second = this;
        }
        synchronized (first) {
            synchronized (second) {
                if (friends.add(that)) {
                    that.friend(this);
                } 
            }
        }
    }

不过这种排序的方式在实践中有许多缺点。代码必须知道所有的锁。对于代码来说,在获得第一个锁之前,很难或者不可能确切知道它需要哪些锁

1.5 线程的一些函数

  • Object.wait();释放某个对象的锁,将该对象加入到等待序列,等待也可以理解为阻塞。
  • Object.notify();从等待序列中唤起一个线程
  • Object.notifyAll();将等待序列中的所有线程唤起
    notify和notifyAll方法智能作为此对象监视器所有着的线程调用。
    一个线程调用其中的上述三者中的任何一个方法,对象必须已经锁定。
    以下是线程的一个状态转换图
    在这里插入图片描述
    参考资料:MIT.6.031-software construction,课件
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值