Java线程同步

文章目录

        • 1. Synchronized 关键字
          • 1.1 synchronized 标记非static方法举例
          • 1.2 synchronized 标记static方法举例
        • 2. volatile 关键字
          • 2.1 可见性问题(visibility)
          • 2.2 volatile 完全可见性保证
          • 2.3 指令重排问题
          • 2.4 Happens-Before 保证
          • 2.5 适用性
        • 3. wait()和notify()
        • 4. 总结

为什么要同步呢?因为一个资源可能同时被多个线程任务修改或访问,这样的话就会造成混乱。为了避免这种混乱,让该资源一次只能被一个线程修改/访问,这就叫做线程同步。


1. Synchronized 关键字

synchronized 关键字标记的代码块称为同步块,在任意给定的时间只允许一个线程执行同步块。

synchronized关键字有三种用法:

  • 用来标记非static方法
  • 用来标记static方法
  • 用来标记代码块

当我们使用同步块的时候,Java会使用监视器(监视器锁(monitor lock))来支持同步,这些监视器锁与对象绑定,因此该对象的所有同步块在同一时刻只能有一个线程来执行。当同步块执行完毕或者发生异常时,会自动释放监视器锁。

//标记非static方法
public synchronized void synchronisedCalculate() {
   setSum(getSum() + 1);
}

//标记static方法
public static synchronized void syncStaticCalculate() {
   staticSum = staticSum + 1;
}

//标记方法中的一段代码块,这里我们传了 `this` 给synchronized,this就是监视器锁的绑定对象
public void performSynchrinisedTask() {
   synchronized (this) {
       setCount(getCount()+1);
   }
}

1.1 synchronized 标记非static方法举例

写一个Counter类,把它的count方法用synchronized关键字标记

public class Counter {    
     public synchronized void count(String threadName){        
             for(int i = 0; i < 3; i++){           
                   try {                
                         Thread.sleep(500);            
                   }catch (InterruptedException e){}            
                   Printer.print("in " + threadName + "; current num is " + i);        
             }    
      }
}

在自定义的Thread类中调用count()方法:

public class TestThread extends Thread {    
      private Counter counter;    
      
      public TestThread(Counter counter){        
            this.counter = counter;    
      }    
      
      @Override    
      public void run() {        
            super.run();        
            counter.count(getName());    
      }
}

创建多个任务同时执行:

Counter tool = new Counter();
TestThread thread1 = new TestThread(tool);
TestThread thread2 = new TestThread(tool);
TestThread thread3 = new TestThread(tool);
thread1.start();
thread2.start();
thread3.start();

运行代码,输出结果如下:
输出1.1.1-1:

in Thread-0; current num is 0
in Thread-0; current num is 1
in Thread-0; current num is 2
in Thread-2; current num is 0
in Thread-2; current num is 1
in Thread-2; current num is 2
in Thread-1; current num is 0
in Thread-1; current num is 1
in Thread-1; current num is 2

可见线程执行count()方法时是一个一个执行的。

假如把count()方法的关键字synchronized去掉,输出的结果如下:
输出1.1.1-2:

in Thread-0; current num is 0
in Thread-2; current num is 0
in Thread-1; current num is 0
in Thread-1; current num is 1
in Thread-2; current num is 1
in Thread-0; current num is 1
in Thread-0; current num is 2
in Thread-2; current num is 2
in Thread-1; current num is 2

没有同步的情况下,三个线程是同时执行count()方法的。

注意: 如果一个对象有多个synchronized方法,某一时刻某个线程已经进入到某个synchronized方法,那么在该方法没有执行完毕之前,其它线程也无法访问该对象的其它任何synchronized方法。


1.2 synchronized 标记static方法举例

若我们给每个线程传不同的实例,如下:

private static void testSynchronized(){    
      Counter counter = new Counter();    
      Counter counter1 = new Counter();    
      Counter counter2 = new Counter();    
      TestThread thread1 = new TestThread(counter);    
      TestThread thread2 = new TestThread(counter1);    
      TestThread thread3 = new TestThread(counter2);    
      thread1.start();    
      thread2.start();    
      thread3.start();
}

其输出结果将和 输出1.1.1-2 一样,因为是三个不同的对象,自然不会有同步问题。

如果我们给Counter的 count() 方法加上static修饰符,将之变成静态方法,如下:

public static synchronized void count(String threadName){        
        for(int i = 0; i < 3; i++){           
                try {                
                     Thread.sleep(500);            
                } catch (InterruptedException e){}            
                Printer.print("in " + threadName + "; current num is " + i);        
         }    
 }

运行上述代码,输出的结果和 输出1.1.1-1 一样是一个线程接另一个线程执行,也就是说,对于静态的synchronized方法,即使是不同的对象,仍然会顺序执行。

我们已经知道,当一个方法被synchronized标记为同步方法,监视器锁会绑定调用方法的对象,但是static方法并不属于某一个对象,而是属于它所在的类,因而监视器锁绑定的是它所在的类的Class对象。对于一个类来说,不管它生成了多少个对象,它们对应的都是同一个Class对象。
所以对于synchronized static方法,即使是向不同的线程传入了同一个类的不同对象,这些线程在调用synchronized static方法时仍然是顺序执行的。


2. volatile 关键字

在多线程中,当线程需要对某个变量操作的时候,为了提高工作性能,线程可能会从主内存中把变量读取到CPU缓存。如果机器上有多个CPU,每个线程可能运行在不同的CPU上,也就意味着可能把主内存中的同一个变量读取到不同的CPU缓存中。然而JVM何时从主内存中读取数据到CPU缓存,或者何时把CPU缓存中的数据写回到主内存都是不确定的,这可能会导致数据的不一致性等许多问题。

volatitle 关键字来标记变量,意味着把变量变为 ”一直存在主内存中“,也是就说,每次读取volatitle变量的时候,都会从主内存中读取,而不是CPU缓存,每次写volatitle变量的时候,都会写到主内存中,而不仅仅只写入到CPU缓存。


2.1 可见性问题(visibility)

当多个线程使用同一个变量,如果某个线程更改了变量的值但尚未把它写回到主内存,导致其它线程无法读取到最新的变量值,这种情况被称为可见性问题。一个线程的更新对其它线程不可见。

volatile标记的变量确保了对其它线程的可见性。

我们定义一个类SharedObject,它拥有一个 volatitle变量counter

public class SharedObject {
      public volatile int counter = 0;
}

假如现在有两个线程T1和T2,T1修改counter的值,而T2读取counter的值,因为使用了volatile,不管T1如何修改,T2总是能读取到最新的值。可以说T1的修改对于T2是可见的。

然而,如果T1和T2都要修改counter的值,比如说给它加1,仅仅使用volatile关键字还不能保证数据的一致性。想象一下T1和T2同时修改counter的值,它们分别把counter的值读取到CPU缓存,然后给它们加1,然后把值写回到主内存。由于 读-加1-写回 需要一段时间,两个线程可能会读到相同的值,两个线程都给counter加了1,最终主线程中的couter值却只增加了1。这就造成了数据的不一致性。这个问题后面再讨论。


2.2 volatile 完全可见性保证

实际上,volatile关键字不仅只保证自身变量的可见性,完全可见性保证还包括:

  • 如果线程A写数据给一个volatile变量,线程B接着读取这个变量。那么对于线程A可见的所有位于该volatile变量之前的其它变量,在线程B读取了volatile变量之后,对线程B同样可见。
  • 如果线程A读取了一个volatile变量,那么对于线程A可见的所有其它变量都会在读取volatile变量时重新从主内存中读取。

举个例子说明下:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

代码中的 update()方法给三个变量写入了值,其中变量days是volatile标记的变量。volatile完全可见性保证意味着,当值被写给days的时候,变量yearsmonths的值也都被写给了主内存。也就是说位于days赋值语句之前的两个变量的赋值,对于其它线程也是可见的。

代码中的 totalDays()方法从读取变量days的值开始,当读取变量days的值的时候,变量monthsyears的值也会从主内存中读取。


2.3 指令重排问题

出于提升性能需要,在不影响指令语义的前提下,JVM和CPU被允许对程序中的指令进行重新排序。例如下面的指令:

int a = 1;
int b = 2;
a++;
b++

上面的四行代码能被进行如下的重新排序而不影响语义:

int a = 1;
a++;
int b = 2;
b++;

然而对于有volatile变量的代码来说,重排序可能会影响程序的执行,比如上面的update()方法可能被重排序成下面的样子:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

由于volatile变量只保证位于它之前的变量的可见性,所以当写数据给months和years的时候,不能确保他们的值被立即写入了主内存,也就是说可能对其它线程不可见。为了解决这一问题,Java给了volatile一个"Happens-Before"保证。


2.4 Happens-Before 保证

volatle的 Happens-Before保证包括以下规则:

  • 如果变量的读写发生在写volatile变量之前,它们不能被重新排序到写volatile变量之后。也就是说,发生在写volatile变量之前的其它变量读写,被保证了 Happens-Before 写volatile变量。
  • 如果变量的读写发生在读volatile变量之后,它们不能被重排序到读volatile变量之前。

2.5 适用性

前面在 2.1 中提到过,如果两个或多个线程同时读写一个volatile变量,可能会出现数据不一致的问题。这时候可能需要用 synchronzied 来确保读写的原子性,因为读写volatile变量不会阻塞其它线程的读写。作为synchronized的替代方法,还可以使用 java.util.concurrent.atomic 包中众多原子数据类型之一,比如AtomicLongAtomicInteger等,该包是一个小型工具包,支持对单个变量进行无锁线程安全编程,这里不详细讲了。

读取和写入volatile变量会使变量被读写到主内存,而读写主内存比读写CPU更消耗性能,而且访问volatitle变量也将阻止指令重排这种性能增强技术。因此,当确实需要强制实施变量可见性时,才使用volatile变量。


3. wait()和notify()

Java中所有的对象都有wait()和notify()方法。简单来说,wait()的作用就是使当前线程处于等待状态直到其它线程对同一个对象调用了notify()或notfiyAll()方法。

为此,当前线程必须已经获取了对象的监视器锁,获取监视器锁有三个途径:

  • 执行了对象的synchronized非静态方法
  • 执行了对象的synchronized代码块
  • 执行了Class对象的静态方法

注意同一时刻只有一个线程能获取一个对象的监视器锁。

解释下wait()和notify()的几个重载方法:

  • wait()
    使当前线程处于等待状态,直到其它线程对该对象调用了notify()或notifyAll()方法。

  • wait(long timeout)
    使当前线程处于等待状态,直到其它线程唤醒它,或者直到timeout自动唤醒。

  • wait(long timeout, int nanos)
    和上面的方法一样

  • notify()
    唤醒所有等待该对象的监视器锁的线程(调用了wait()方法)中的任意一个,不确定究竟会唤醒哪一个线程。

  • notifyAll()
    唤醒所有等待该对象的监视器锁的线程,被唤醒的线程将竞争该对象的监视器锁。


4. 总结

关于线程同步,除了这些基础的用法,Java还推出了新的两个类 LockCondition 来实现线程同步,它们的功能更加丰富,但这里暂时不写,或许等有时间了再补上。

你可能感兴趣的:(volatile,synchronized,notify,wait,Java)