03、Java并发编程:线程的同步

在现实开发中,我们或多或少的都经历过这样的情景:某一个变量被多个用户并发式的访问并修改,如何保证该变量在并发过程中对每一个用户的正确性呢?今天我们来聊聊线程同步的概念。

一般来说,程序并行化是为了获得更高的执行效率,但前提是,高效率不能以牺牲正确性为代价。如果程序并行化后, 连基本的执行结果的正确性都无法保证, 那么并行程序本身也就没有任何意义了。因此, 线程安全就是并行程序的根本和根基。解决这些问题从临界区的概念开始。临界区是访问一个共享资源在同一时间不能被超过一个线程执行的代码块。

java为我们提供了同步机制,帮助程序员实现临界区。当一个线程想要访问一个临界区,它使用其中的一个同步机制来找出是否有任何其他线程执行临界区。如果没有,这个线程就进入临界区。否则,这个线程通过同步机制暂停直到另一个线程执行完临界区。当多个线程正在等待一个线程完成执行的一个临界 区,JVM选择其中一个线程执行,其余的线程会等待直到轮到它们。临界区有如下的规则:

  1. 如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入。
  2. 任何时候,处于临界区内的进程不可多于一个。如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待。
  3. 进入临界区的进程要在有限时间内退出,以便其它进程能及时进入自己的临界区。
  4. 如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等”现象。

java语言为解决同步问题帮我们提供了两种机制来实现:

1. synchronized关键字;
2.  Lock锁及其实现;

synchronized的作用

关键字synchronized 的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次, 只能有一个线程进入同步块,从而保证线程间的安全性。

关键宇synchronized 可以有多种用法。这里做一个简单的整理。

* 指定加锁对象: 对给定对象加锁,进入同步代码前要获得给定对象的锁。
* 直接作用于实例方法: 相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
. 直接作用于静态方法: 相当于对当前类加锁, 进入同步代码前要获得当前类的锁。

1、 给指定对象加锁:;

    public class AccountingSync implements Runnable{
   
     
        static AccountingSync instance=new AccountingSync() ;
        static int i =O;
        @Override
        public void run() (
        for(int j=O; j<lOOOOOOO; j++) {
            synchronized (instance) {   //对象锁
                i++ ;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException (
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }

    /*

    public static void main(String[] args) throws InterruptedException (
        Thread t1=new Thread(new AccountingSync());
        Thread t2=new Thread(new AccountingSync());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }

    */

知道我为什么要给出两个main方法让大家参考吗?上述锁对象是锁定AccountingSync实例对象。第一个main方法中t1 和 t2 两个线程同时指向了instance实例,所以第7行的锁对象synchronized (instance)在线程t1 和 线程 t2 获得锁的时候是获取同一个对象的,这个时候的锁是同一把锁。但是在第二个main方法中我们可以看到线程t1 和 线程 t2分别对应的是两个不同的AccountingSync对象,这时候锁对象获得的是不同的AccountingSync实例,安全性是没有保证的,大家可以动手尝试一下。

2、 直接作用于实例方法:;

    public class TestSynchronized {
   
     
        public static void main(String[] args) {
            Tester2 a1 = new Tester2();
            Th t1 = new Th(a1);
            t1.start();
            Th t2 = new Th(a1);
            t2.start();
        }

    }
    class Tester2 {
   
     
        public synchronized void say(String name) throws InterruptedException{
            for(int i = 0;i<5;i++){
                Thread.sleep(1000);
                System.out.println();
                System.out.println(name +","+i+new Date().toLocaleString() );
            }
        }
    }
    class Th extends Thread{
   
     
        Tester2 test;
        public Th(Tester2 test1){
            test = test1;
        } 
        public void run(){
            try {
                test.say(Thread.currentThread().getName());
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    }

对Tester2类中的方法使用synchronized很好理解,同一时刻如果t1正在调用say()方法,在他没有执行完毕并退出方法之前其余的线程是无法获得该方法的。只能排队等待知道t1执行完毕。

3、 作用于静态方法:;

 public class Test1 {
   
     
        public static void main(String[] args) {
            for(int i=0;i<50;i++){
                Thread t1 = new Thread(new Sale(5));
                Thread t2 = new Thread(new Producted(5));
                t1.start();
                t2.start();
            }
        }
    }

    class Shop{
   
     
        static int a = 40;
        synchronized static void shopping(int b){
            a -= b;
            System.out.println("售出  "+b+"  张大饼,"+"还剩  "+a+" 张大饼");
        }

        synchronized static void factory(int c){
            a += c;
            System.out.println("仓库还有  "+a+"  张大饼");
        }
    }

    class Sale implements Runnable{
   
     
        int b = 0;
        public Sale(int b){
            this.b = b;
        }

        @Override
        public void run() {
            if(b<0){
                Thread.interrupted();
            }
            Shop.shopping(b);
            try {
                Thread.sleep(1000);
                Shop.factory(b-5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    class Producted implements Runnable{
   
     
        int b = 0;
        public Producted(int b){
            this.b = b;
        }

        @Override
        public void run() {
            Shop.factory(b);
            try {
                Thread.sleep(1000);
                Shop.shopping(b-5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

静态方法前加synchronized这个锁等价于锁住了当前类的class对象,因为静态方法或者是静态关键字在本质上是一个类对象,而不是成员对象,在内存中位于方法区被所有的实例共享。即等同于synchronized(Shop.class)。我们需要注意的是锁住了类并不代表锁住了类所在的对象,类本身也是一种对象。它与类的实例是完全不同的两个对象,在加锁时不是相互依赖的,即对类加锁并不与上面例子中的加锁互斥,锁住了子类或子类的对象与锁住父类或父类的对象是不相关的。

synchronized的使用其实主要是前面两种,对象锁和方法锁,静态方法锁我们并不常用到。其余的操作方式都是在这两种的基础上演变而来,比如大家经常说的“块级锁”:

    synchronized(object){
        //代码内容
    }

锁住的其实并不是代码块,而是object这个对象,所以如果在其他的代码中
也发生synchronized(object)时就会发生互斥。我们为什么要研究这些呢,因为如果我们不知道我们锁住的是什么,就不清楚锁住了多大范围的内容,自然就不知道是否锁住了想要得到互斥的效果,同时也不知道如何去优化锁的使用。

因此java中的synchronized就真正能做到临界区的效果,在临界区内多个线程的操作绝对是串行的,这一点java绝对可以保证。同时synchronized造成的开销也是很大的,我们如果无法掌握好他的粒度控制,就会导致频繁的锁征用,进入悲观锁状态。

volatile—-轻量级的synchronized

既然我们说到了synchronized那就不得不提到volatile,在java中synchronized是控制并发的,我们知道在我们对一个变量执行赋值操作的时候比如:i++,在执行完毕之后i的结果其实是写到缓存中的它并没有及时的写入到内存,后续在某些情况下(比如cpu缓存不够)再将cpu缓存写入内存,假设A线程正在执行i++操作,而此时B线程也来执行。B在执行i++之前是不会自己跑到缓存中去取变量的值的,它只会去内存中读取i,很显然i的值是没有被更新的,为了防止这种情况出现,volatile应运而生。

Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

我们来看一个例子:

    public class TestWithoutVolatile {
   
     
        private static boolean bChanged;  

        public static void main(String[] args) throws InterruptedException {  
            new Thread() {  
                @Override  
                public void run() {  
                    for (;;) {  
                        if (bChanged == !bChanged) {  
                            System.out.println("!=");  
                            System.exit(0);  
                        }  
                    }  
                }  
            }.start();  
            Thread.sleep(1);  
            new Thread() {  
                @Override  
                public void run() {  
                    for (;;) {  
                        bChanged = !bChanged;  
                    }  
                }  
            }.start();  
         }  
    }

在上例中我们如果多次运行会出现两种结果,一种是正常打印:”!=”,还有一种就是程序会陷入死循环。但是我们如果给bChanged前面加上volatile的话则每次都会打印出”!=”,请读者朋友们下去可以尝试。
在此处没有加volatile之前之所以会出现有时可以出现正确结果有时则卡死的原因就在于两个线程同时在运行的过程中双方都在操作bChanged变量,但是该变量的值对于同时在使用它的另一个线程来说并不总是可见的,运气好的时候线程修改完值之后就写入主存,运气不好的时候线程只在缓存中更新了值并未写入主存。但是在加了volatile修饰之后效果则不同,因为volatile可以保证变量的可见性。
说到可见性,我们来看一幅图:
*

每一个线程都有相应的工作内存,工作内存中有一份主内存变量的副本,线程对变量的操作都在工作内存中进行(避免再次访问主内存,提高性能),不同线程不能访问彼此的工作内存,而通过将操作后的值刷新到主内存来进行彼此的交互,这就会带来一个变量值对其他线程的可见性问题。当一个任务在工作内存中变量值进行改变,其他任务对此是不可见的,导致每一个线程都有一份不同的变量副本。而volatile恰恰可以解决这个可见性的问题,当变量被volatile修饰,如private volatile int stateFlag = 0; 它将直接通过主内存中被读取或者写入,线程从主内存中加载的值将是最新的。

但是volatile的使用有着严格的限制,当对变量的操作依赖于以前值(如i++),或者其值被其他字段的值约束,这个时候volatile是无法实现线程安全的。被volatile修饰的变量必须独立于程序的其他状态。因为volatile只是保证了变量的可见性,并不能保证操作的原子性,所谓原子性,即有“不可分”的意思,如对基本数据类型(java中排除long和double)的赋值操作a=6,如返回操作return a,这些操作都不会被线程调度器中断,同一时刻只有一个线程对它进行操作。
看以下代码:

public class Counter {
        public volatile static int count = 0;
        public static void inc() {

            //这里延迟1毫秒,使得结果明显
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {

            }
            count++;
        }

        public static void main(String[] args) {
            //同时启动1000个线程,去进行i++计算,看看实际结果
            for (int i = 0; i < 1000; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        Counter.inc();
                    }
                }).start();
            }
            //这里每次运行的值都有可能不同,可能为1000
            System.out.println("运行结果:Counter.count=" + Counter.count);
        }
    }

运行上面的例子我们可以发现每次运行的结果都不一样,预期结果应该是1000,尽管counter被volatile修饰,保证了可见性,但是counter++并不是一个原子性操作,它被拆分为读取和写入两部分操作,我们需要用synchronized修饰:

    publicstaticsynchronizedvoid incNum() {
   
     
        counter++;
    }

此时每次运行结果都是1000,实现了线程安全。synchronized是一种独占锁,它对一段操作或内存进行加锁,当线程要操作被synchronized修饰的内存或操作时,必须首先获得锁才能进行后续操作;但是在同一时刻只能有一个线程获得相同的一把锁,所以它只允许一个线程进行操作。synchronized同样能够将变量最新值刷新到主内存,当一个变量只被synchronized方法操作时,是没有必要用volatile修饰的,所以我们接着把变量声明修改为:

    private static int counter;

多次运行结果依旧是1000。

说明
上例中如果你按照上面这样改完之后其实结果并是不1000,我多次运行的结果都是先打印出”运行结果:Counter.count=0”,然后线程卡死。究其原因,我猜可能是第一个线程等待一秒再执行count++,然后后面的线程在这个等待过程中等不及的原因。java线程的运行具有不确定性,不能保证线程会按部就班的顺序执行,所以会出现什么样的后果很难预测。
正确结果代码如下:

public class Counter {
        public static int count = 0;
        public synchronized static void inc() {     
            count++;
        }

        public static void main(String[] args) {
            //同时启动1000个线程,去进行i++计算,看看实际结果
            for (int i = 0; i < 1000; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        Counter.inc();
                    }
                }).start();
            }
            //这里每次运行的值都有可能不同,可能为1000
            System.out.println("运行结果:Counter.count=" + Counter.count);
        }
    }

综上所述,由于volatile只能保证变量对多个线程的可见性,但不能保证原子性,它的同步机制是比较脆弱的,它在使用过程中有着诸多限制,对使用者也有更高的要求,相对而言,synchronized锁机制是比较安全的同步机制,有时候出于提高性能的考虑,可以利用volatile对synchronized进行代替和优化,但前提是你必须充分理解其使用场景和涵义。

下一节我们接着分析Lock锁。