【Java】Java关键字 - synchronized关键字

Posted by 西维蜀黍 on 2019-01-31, Last Modified on 2021-09-21

为什么要引入同步机制

在多线程环境中,可能会有两个甚至更多的线程试图同时访问一个有限的资源。必须对这种潜在资源冲突进行预防。

用一个取钱的程序例子,来说明为什么需要引入同步:

public class Main {
    public static void main(String[] args) {
        Bank bank = new Bank();

        Thread t1 = new MoneyThread(bank);// 从银行取钱线程
        Thread t2 = new MoneyThread(bank);// 从取款机取钱线程

        t1.start();
        t2.start();
    }
}

class Bank {
    private int money = 1000;

    public int depositMoney(int amount) {
        if (amount < 0) {
            return -1;
        } else if (amount > money) {
            return -2;
        } else if (money < 0) {
            return -3;
        } else {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            money -= amount;

            System.out.println("Left Money: " + money);
            return amount;
        }
    }
}

class MoneyThread extends Thread {
    private Bank bank;

    public MoneyThread(Bank bank) {
        this.bank = bank;
    }

    @Override
    public void run() {
        int result = bank.depositMoney(800);
        if(result == -1 || result == -2 || result == -3)
            System.out.println("Deposit fails");
        else
           System.out.println("Depositing " + result + " succeeds");
    }
}

执行结果

Left Money: 200
Deposit 800 successfully
Left Money: 200
Deposit 800 successfully

分析

程序中定义了一个Bank类,其中包含了用户存储的钱(1000元),然后用两个线程进行取钱操作,可以看到尽管Bank类中的getMoney()方法对取钱数目与存款数据进行了判断,但是执行后,结果输出两个800,表明从两个线程中都成功地取出了800元钱。此后银行中的钱金额为-600。

这是因为,在depositMoney()方法中,当进入最后一个else语句块后,有一个简短的休眠,那么在第一个线程休眠的过程中,第二个线程也成功进入了这个else语句块(因为存款的钱还没有被线程一取走)。当两个线程结束休眠后,不再进行逻辑判断而是直接将钱取走,所以两个线程都取到了800元钱,此后money为-600。


由于我们设置了线程的休眠时长为1s(时长较长),因此很有可能两个线程都已经成功进入else语句块。

若我们缩小休眠时长(比如5ms),当一个线程率先执行到了else块时,另一个线程是否执行到了else块是未知的,且执行到了else快的哪一行代码(由 JVM 对线程的调度决定)。因此,可能会出现以下的任何一种输出情况:

Left Money: -600
Left Money: -600
Deposit 800 successfully
Deposit 800 successfully

Deposit fails
Left Money: 200
Depositing 800 succeeds

Left Money: 200
Left Money: 200
Depositing 800 succeeds
Depositing 800 succeeds

Left Money: 200
Depositing 800 succeeds
Left Money: -600
Depositing 800 succeeds

解决办法

显然这样的取款机制很不可靠的,我们必须要通过引入同步机制。以保证程序的执行是如我们预期的。

因此,在多线程访问资源的情况时,我们为访问资源的第一个线程上锁,此后其他线程便不能再使用那个资源,除非该锁被解除。

换句话说,如果两个线程同时操作对象中的实例变量,则会出现“非线程安全”问题。

Java的synchronized关键字为我们提供了一套锁机制,以解决非线程安全问题,我们来看看使用了synchronized上面的程序有什么变化:

注意,以下程序与上面的实现唯一的区别在于我们使用synchronized关键字修饰了 getMoney(int number)方法。

public class Main {
    public static void main(String[] args) {
        Bank bank = new Bank();

        Thread t1 = new MoneyThread(bank);// 从银行取钱线程
        Thread t2 = new MoneyThread(bank);// 从取款机取钱线程

        t1.start();
        t2.start();
    }
}

class Bank {
    private int money = 1000;

    public synchronized int depositMoney(int amount) {
        if (amount < 0) {
            return -1;
        } else if (amount > money) {
            return -2;
        } else if (money < 0) {
            return -3;
        } else {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            money -= amount;
            System.out.println("Left Money: " + money);
            return amount;
        }
    }
}

class MoneyThread extends Thread {
    private Bank bank;

    public MoneyThread(Bank bank) {
        this.bank = bank;
    }

    @Override
    public void run() {
        int result = bank.depositMoney(800);
        if(result == -1 || result == -2 || result == -3)
            System.out.println("Deposit fails");
        else
           System.out.println("Depositing " + result + " succeeds");
    }
}

执行结果

稳定地输出:

Left Money: 200
Depositing 800 succeeds
Deposit fails

分析

这表明第一次取款800元后,剩余200元。当另一个线程再去取的时候,已经不能再取钱了。

一个线程开始执行取钱的方法之后就阻止了其他线程再去执行这个方法,直到本线程结束,其他线程才有访问权利。

synchronized关键字含义

多线程的同步机制对资源进行加锁,使得在同一个时间,只有一个线程可以进行操作,同步用以解决多个线程同时访问时可能出现的问题。

同步机制可以使用synchronized关键字实现。

根据synchronized用的位置可以有这些使用场景:

![image-20190201121634389](assets/image-20190201121634389.png

synchronized关键字在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成。

synchronized修饰方法 - 同步方法

**当synchronized关键字修饰一个方法的时候,该方法叫做同步方法。**这个方法是一个原子操作,即若多个线程都去执行这个方法时,从其中的一个线程开始执行这个方法,到执行完成的过程中,其他线程都必须进行等待。

当synchronized方法执行完或发生异常时,会自动释放锁。

例子1 - 是否使用synchronized关键字的不同

public class Main {
    public static void main(String[] args)
    {
        Example example = new Example();

        Thread t1 = new Thread1(example);
        Thread t2 = new Thread1(example);

        t1.start();
        t2.start();
    }
}

class Example {
    public void execute() {
        for (int i = 0; i < 5; ++i) {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Hello: " + i);
        }
    }
}

class Thread1 extends Thread
{
    private Example example;

    public Thread1(Example example)
    {
        this.example = example;
    }

    @Override
    public void run()
    {
        example.execute();
    }
}

执行结果

Hello: 0
Hello: 1
Hello: 2
Hello: 3
Hello: 4
Hello: 0
Hello: 1
Hello: 2
Hello: 3
Hello: 4

总结

是否在execute()方法前加上synchronized关键字,这个例子程序的执行结果会有很大的不同。

如果不加synchronized关键字,则两个线程同时并行地执行execute()方法,输出是两组并发的。

如果加上synchronized关键字,则会先输出一组0到4,然后再输出下一组0到4,说明两个线程是顺次执行的。

例子2 - 同一个类中包含多个同步方法

代码

public class Main {
        public static void main(String[] args)
        {
            Example example = new Example();

            Thread t1 = new Thread1(example);
            Thread t2 = new Thread2(example);

            t1.start();
            t2.start();
        }
    }

    class Example
    {
        public synchronized void execute()
        {
            for (int i = 0; i < 5; ++i)
            {
                try
                {
                    Thread.sleep((long) Math.random() * 1000);
                }
                catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
                System.out.println("Hello: " + i);
            }
        }

        public synchronized void execute2()
        {
            for (int i = 0; i < 5; ++i)
            {
                try
                {
                    Thread.sleep((long) Math.random() * 1000);
                }
                catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
                System.out.println("World: " + i);
            }
        }

    }

    class Thread1 extends Thread
    {
        private Example example;

        public Thread1(Example example)
        {
            this.example = example;
        }

        @Override
        public void run()
        {
            example.execute();
        }
    }

    class Thread2 extends Thread
    {
        private Example example;

        public Thread2(Example example)
        {
            this.example = example;
        }

        @Override
        public void run()
        {
            example.execute2();
        }
    }

执行结果

Hello: 0
Hello: 1
Hello: 2
Hello: 3
Hello: 4
World: 0
World: 1
World: 2
World: 3
World: 4

说明

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

Java中的每个对象都有一个锁(lock),或者叫做监视器(monitor),当一个线程访问某个对象的synchronized方法时,将该对象上锁,其他任何线程都无法再去访问该对象的synchronized方法了(这里是指所有的同步方法,而不仅仅是同一个方法),直到之前的那个线程执行方法完毕后(或者是抛出了异常),才将该对象的锁释放掉,其他线程才有可能再去访问该对象的synchronized方法。

注意这时候是给对象上锁,如果是不同的对象,则各个对象之间没有限制关系。即若尝试在代码中构造第二个线程对象时传入一个新的 Example 对象,则两个线程的执行之间任何什么制约关系。

例子3 - 静态的同步方法

当一个synchronized关键字修饰的方法同时又被static修饰,之前说过,非静态的同步方法会将对象上锁,但是静态方法不属于对象,而是属于类,它会将这个方法所在的类的Class对象上锁

代码

import java.util.List;
public class Main {
    public static void main(String[] args) {
        Example example = new Example();

        Thread t1 = new Thread1(example);

        // 此处即便传入不同的对象,静态方法同步仍然不允许多个线程同时执行
        example = new Example();

        Thread t2 = new Thread2(example);

        t1.start();
        t2.start();
    }
}

class Example {
    public synchronized static void execute() {
        for (int i = 0; i < 5; ++i) {
            try {
                Thread.sleep((long) Math.random() * 1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Hello: " + i);
        }
    }

    public synchronized static void execute2() {
        for (int i = 0; i < 5; ++i) {
            try {
                Thread.sleep((long) Math.random() * 1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("World: " + i);
        }
    }
}

class Thread1 extends Thread {
    private Example example;

    public Thread1(Example example) {
        this.example = example;
    }

    @Override
    public void run() {
        Example.execute();
    }
}

class Thread2 extends Thread {
    private Example example;

    public Thread2(Example example) {
        this.example = example;
    }

    @Override
    public void run() {
        Example.execute2();
    }
}

运行结果

Hello: 0
Hello: 1
Hello: 2
Hello: 3
Hello: 4
World: 0
World: 1
World: 2
World: 3
World: 4

分析

所以如果是静态方法的情况(execute()和execute2()都加上static关键字),即便是向两个线程传入不同的 Example 对象,这两个线程仍然是互相制约的,必须先执行完一个,再执行下一个。

如果某个synchronized方法是static的,那么当线程访问该方法时,它锁的并不是synchronized方法所在的对象,而是synchronized方法所在的类所对应的Class对象。Java中,无论一个类有多少个对象,这些对象会对应唯一一个Class对象,因此当线程分别访问同一个类的两个对象的两个static,synchronized方法时,它们的执行顺序也是顺序的,也就是说一个线程先去执行方法,执行完毕后另一个线程才开始。

synchronized块

synchronized块写法:

synchronized(object)
{      

}

表示线程在执行的时候会将object对象上锁。(注意这个对象可以是任意类的对象,也可以使用this关键字)。

这样就可以自行规定上锁对象。 

例子4

public class Main {

    public static void main(String[] args) {
        Example example = new Example();

        Thread t1 = new Thread1(example);
        Thread t2 = new Thread2(example);

        t1.start();
        t2.start();
    }

}

class Example {
    private Object object = new Object();

    public void execute() {
        synchronized (object) {
            for (int i = 0; i < 5; ++i) {
                try {
                    Thread.sleep((long) Math.random() * 1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Hello: " + i);
            }

        }

    }

    public void execute2() {
        synchronized (object) {
            for (int i = 0; i < 5; ++i) {
                try {
                    Thread.sleep((long) Math.random() * 1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("World: " + i);
            }

        }

    }

}

class Thread1 extends Thread {
    private Example example;

    public Thread1(Example example) {
        this.example = example;
    }

    @Override
    public void run() {
        example.execute();
    }

}

class Thread2 extends Thread {
    private Example example;

    public Thread2(Example example) {
        this.example = example;
    }

    @Override
    public void run() {
        example.execute2();
    }

}

执行结果

Hello: 0
Hello: 1
Hello: 2
Hello: 3
Hello: 4
World: 0
World: 1
World: 2
World: 3
World: 4

分析

例子程序4所达到的效果和例子程序2的效果一样,即都是使得两个线程的执行顺序进行,而不是并发进行。

当一个线程执行时,将object对象锁住,另一个线程就不能执行对应的块。

synchronized方法(即同步方法)实际上等同于用一个synchronized块包住方法中的所有语句,然后在synchronized块的括号中传入this关键字(以锁住当前对象)。当然,如果是静态方法,需要锁定的则是class对象。

因为可能一个方法中只有几行代码会涉及到线程同步问题,

所以synchronized块比synchronized方法更加细粒度地控制了多个线程的访问,只有synchronized块中的内容不能同时被多个线程所访问,方法中的其他语句仍然可以同时被多个线程所访问(包括synchronized块之前的和之后的)。

注意:被synchronized保护的数据应该是私有的

因此:

  • **synchronized方法(同步方法)**是一种粗粒度的并发控制,某一时刻,只能有一个线程执行该synchronized方法;
  • synchronized块则是一种细粒度的并发控制,只会将块中的代码同步,位于方法内、synchronized块之外的其他代码是可以被多个线程同时访问到的。

synchronized的底层实现

我们来看看synchronized的具体底层实现。先写一个简单的demo:

public class SynchronizedDemo {
    public static void main(String[] args) {
        synchronized (SynchronizedDemo.class) {
        }
        method();
    }

    private synchronized static void method() {
    }
}

上面的代码中有一个同步代码块,锁住的是 SynchronizedDemo 类对象。还有一个同步静态方法,锁住的依然是 SynchronizedDemo 类对象。用javap -v SynchronizedDemo.class查看字节码文件:

如图,上面用黄色高亮的部分就是需要注意的部分了,这一部分与synchronized关键字对应。

执行同步代码块前,首先要先执行 monitorenter 指令,退出的时候 monitorexit 指令。通过分析之后可以看出,使用synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。

这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。

在上面的例子中,在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个 SynchronizedDemo 类对象。

那么这个正在执行的线程还需要获取该锁吗?答案是不必的。

从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一

任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块或同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,因而进入到阻塞状态。


下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就被放入同步队列(Synchronized Queue),线程状态被置为阻塞状态,当Object的监视器占有者释放后,在同步队列中的线程就会有机会重新获取该监视器。

synchronized的讨论

在 Java 并发编程这个领域中,synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为“重量级锁”。但是,在 Java SE 1.6之后,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁、轻量级锁以及其它的优化(比如适应性自旋),相对于synchronized而言,后者更为轻量级。

Reference