java-并发-同步

时间:2023-03-10 07:14:39
java-并发-同步

  浏览以下内容前,请点击并阅读 声明

  线程间的通信主要是通过开放对于字段以及字段引用的对象的访问权限实现,这种形式的通信非常高效,但是会产生两种可能的错误:线程冲突和内存一致性错误,防止这些错误的工具就是同步。

  然而,同步可能引入线程争夺,当两个两个或者连个以上线程试图同时访问同一资源时就会发生资源线程争夺,这样会导致java运行时执行一个或多个线程时更加缓慢,甚至是暂停执行,饥饿以及活锁是线程争夺的一种形式。

线程冲突

//定义一个类
class Counter {
private int c = 0; public void increment() {
c++;
} public void decrement() {
c--;
}
public int value() {
return c;
}
}

  上述代码中定义的类,如果调用increment,则c加一,调用decrement,则c减一,当多个线程同时引用同一个一个Counter实例时,就可能产生冲突。当连个线程对一个对象同时进行操作时,产生交叉,得到的是不可预料的结果,线程冲突的bug很难检测修复。

内存一致性错误

  当不同的线程对于什么才是相同的数据有不一致的观点时,就会产生内存一致性错误,程序员无需知道产生内存一致性错误的原因,只需要知道如何避免这些错误。

  避免内存一致性错误的关键是了解发生前关系,这种关系就是简单的保证被一个特定语句写入的内存对于另一个特定的语句是可见的。如:

//定义并初始化
int counter = 0;
....
//字段加1
counter++;
//打印字段
System.out.println(counter);

  以上所有语句如果是在同一个线程内发生,则输出时认为counter为1没有什么问题,如果counter加1 和输出在两个线程内进行,那么打印的值很可能是0,因为没办法保证两个线程内的操作是互相可见的,除非程序员在两个语句执行之前建立发生前关系。 建立发生前关系的动作有多种,其中一种就是同步。

  之前我们已经见识到了两种建立发生前关系的动作了:

  • 当一个语句调用Thread.start方法时,所有与该语句有发生前关系的语句将与新的线程中所有执行的语句有发生前关系,所有导致新的线程产生的语句所产生的影响对于新的线程是可见的。
  • 当一个线程执行完毕,并使另一个线程的Thread.join方法返回时,已经执行完毕的线程中执行的语句和调用join方法的线程的调用join方法以下的语句有发生前关系,即其影响对于以后的语句可见。

  请参考Summary page of the java.util.concurrent包查看产生发生前关系的动作列表。

同步方法

  java编程语言提供两种基本的同步用法:同步方法和同步语句,后者更加复杂。

  要使一个方法同步,只要在方法声明中加入synchronized关键词即可,如:

public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}

  使方法同步的作用有两个:

  • 首先,不能有两次同时调用一个对象的同步方法,当一个线程正在执行一个对象的同步方法时,其他调用该对象的同步方法的线程就会被封堵(暂停执行),直到前面的线程完成了对该对象的操作为止。
  • 其次,当一个同步方法执行完毕返回时,该同步方法自动与随后被调用的同一个对象的同步方法建立发生前关系。保证了对于该对象状态的改变对于所有的线程都是可见的。

  需要注意的是,构造器无法被同步,对构造器使用synchronized关键词是一个语法错误,同步构造器没有意义,因为一个对象在创建时,只有创造该对象的线程能对其进行访问。

内置的锁和同步

  同步是围绕一个叫做内置锁或者监视锁的内部实体所建立的,内置锁负责同步的两个方面:加强对一个对象状态的排他访问和建立对于可见性至关重要的发生前关系。

  每一个对象都有一个自身相关的内置锁,一般情况下,一个线程如果要对一个对象的字段进行排他的和一致的访问,要先请求对象的内置锁,当访问结束时再释放所。只要一个线程拥有一个锁,其他的线程就不能请求同样的锁,其他试图请求该锁的线程将会被阻止。当一个线程释放了一个内置锁,该动作和任何随后请求同样锁的动作就会建立发生前关系。

同步方法中的锁

  当一个线程调用一个同步方法时就会自动请求该方法所在对象的内置锁,当方法返回时再释放其内置锁,即使是由于未捕获的异常导致的方法返回,锁也会被释放。

  当一个静态的同步方法被调用时,因为静态方法与一个类相关,而不是与一个实例对象相关,所有该线程就会请求与该类相关的Class对象的内置锁,因此访问一个类的静态字段是由与实例相关的锁不同的内置锁控制的。

同步语句

  除同步方法意外,另外一个创建同步代码的方法就是使用同步语句,与同步方法不同的是,同步语句必须指定提供内部锁的对象:

public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}

  以上方法声明中需要对lastName和nameCount字段进行同步,同时需要避免同步调用其他对象的方法,没有同步语句,则仅仅是语句nameList.add(name)就需要另外再定义非同步方法。

  同步语句也可以用来改善含有同步并发。例如,以下代码中类MsLunch中有两个实例字段c1和c2,所有对于这些字段的更新必须同步,而两个字段从来不会同时使用,所以一个线程在更新c1时,没有必要阻止另外一个线程对c2的更新,此时可以分别创建两个对象来提供锁。

public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object(); public void inc1() {
synchronized(lock1) {
c1++;
}
} public void inc2() {
synchronized(lock2) {
c2++;
}
}
}

再入同步

  线程不能请求其他线程拥有的锁,但是可以再次请求已拥有的锁,允许一个线程多次请求同一个锁使得再入同步成为可能。一段同步代码直接或间接调用一个含有同步代码的方法,而两端同步代码使用同一个锁,这种情况就叫再入同步。没有再入同步,同步代码就要采取许多额外的预防措施以避免一个线程自己被锁定。

  原子访问

  编程语言中,一个原子动作就是所有一次性有效发生的动作, 原子动作不能在中间停止,要么完成,或者干脆不发生。原子动作没有完成,那么其任何影响都是不可见的。

  我们之前见到的自增加表达式 i++ ,不是一个原子动作,所有的简单的表达式可以细分为多个的动作。另外一些就可以视为原子动作:

  • 对于引用变量和大部分基本数据类型的变量(除了long和double之外)的读和写操作是原子的
  • 对于所有声明为volatile(包括long和double)的变量的读和写操作都是原子的

  原子动作不能交叉执行,因此不必担心线程的冲突问题。然而这并不表示可以省去所有的同步原子动作,因为内存一致性错误依然可能出现。使用volatile变量可减少内存一致性错误,因为所有对于一个volatile变量的写操作都将和接下来的读操作建立发生前关系。这意味着一个volatile变量的变化对于其他的线程是可见的,另外,这也意味着当一个线程读取一个volatile变量的时候,该线程不仅知道对于volatile变量的最后一次更改,而且也知道更改volatile变量的代码执行产生的影响。

  使用简单的原子变量访问比通过同步代码访问这些变量更加高效,但是需要程序员花作更多工作以避免内存一致性错误,这些工作是否值得取决于程序的体量以及复杂程度。

  java.util.concurrent包中的一些类提供了一些不依赖于同步的原子方法,一下的高并发对象会有介绍。