Java同步机制之synchronized

Java并发系列番外篇——同步机制(一)

隐式锁,又称线程同步synchronized。保证在同一时刻最多只有一个线程执行该段代码

前言

在上篇文章《线程安全性》中,提到了Java提供了一种内置的锁机制来支持原子性性,也就是使用synchronized修饰代码块或者方法:

synchronized (lock){
    //被保护的代码块
}
public synchronized void method() {
    被保护的方法
}

每个Java对象都可以用来作为一个同步锁,即内置锁(监视器锁)。线程在进入同步代码块之前会自动获取锁,并且在退出的时候释放锁。获得锁唯一的方法就是进入由这个锁保护的同步代码块或者方法。Synchronized是Java中解决并发问题的一种最常用最简单的方法,它可以确保线程互斥的访问同步代码。

synchronized同步方法

非线程安全问题存在于实例变量中,如果变量是方法内部的私有变量,则这个变量是安全的,不存在线程安全问题:

public void add() {
        int a = 0;
        if (a < 200) {
            a++;
        } else {
            todo()
        }

    }

add就是一个线程安全的方法,因为它的内部变量a是私有的,而且它不持有外部变量。如果没有共享资源,就没有同步的必要。

用synchronized修饰方法

如果多个线程共同访问同一个对象中的实例变量,就有可能出现非线程安全的问题,例如下面的代码,两个线程对同一个对象中的变量各进行加一操作两万次:

public class AddTest {
    private int num = 0;

    public int getNum(){
        return num;
    }
    public void addOne(){
        num++;
    }
}

public class ThreadAdd extends Thread {

    private AddTest mAddTest;

    public ThreadAdd(AddTest addTest){
        this.mAddTest = addTest;
    }

    @Override
    public void run() {
        super.run();
        for (int i = 0;i<20000;i++) {
            mAddTest.addOne();
        }
        System.out.println("ThreadAdd:"+mAddTest.getNum());
    }
}

public static void main(String[] args) {
    AddTest addTest = new AddTest();
    ThreadAdd threadAdd1 = new ThreadAdd(addTest);
    ThreadAdd threadAdd2 = new ThreadAdd(addTest);
    threadAdd1.start();
    threadAdd2.start();
}

上述代码的输出结果是无法确定的,下面是它的执行结果之一:

image

代码总是无法按照我们的预期打印出40000(原因:线程安全中的原子性),如果两个线程同时操作addTest中的变量,则可能会出现线程安全性问题。我们使用synchronizedAddTest方法进行同步:

public class AddTest {
    private int num = 0;

    public int getNum(){
        return num;
    }
    public synchronized void addOne(){
        num++;
    }
}

代码执行结果如下:

image

当两个线程同时对addTestaddOne方法进行操作,只有一个线程能够抢到锁。这个锁为当前的实例对象addTest,一个线程获取了该对象锁(实例锁)之后,其他线程无法获取该对象的锁,就不能访问该对象的synchronized方法,<u>但是可以访问非synchronized修饰的方法</u>。
上文中的代码里只有一个实例addTest,所有两个线程争夺同一把锁,但是如果有多个实例,也就是有多把锁会是什么情况呢:
我们稍微修改一下上文代码中线程创建的方式:

AddTest addTest1 = new AddTest();
AddTest addTest2 = new AddTest();
ThreadAdd threadAdd1 = new ThreadAdd(addTest1);
ThreadAdd threadAdd2 = new ThreadAdd(addTest2);
threadAdd1.start();
threadAdd2.start();

这段代码创建了两个实例,并分别在两个线程执行它们各自的方法。代码执行输出如下:


image

两个线程互不干扰(实际上他们是交替异步执行的)。当多个线程访问多个对象的,JVM会创建多个锁,每个锁只是锁着它对应的实例。不同的线程持有不同的锁,访问不同的对象。
在调用synchronized修饰的方法时,线程一定是排队运行的,只有共享资源的读写才需要同步,如果不是共享资源,根本就没有同步的必要。
接下来我们修改一下AddTest:

public class AddTest {
    private static int num = 0;

    public int getNum(){
        return num;
    }
    public static synchronized void addOne(){
        num++;
    }
}

AddTest addTest1 = new AddTest();
AddTest addTest2 = new AddTest();
ThreadAdd threadAdd1 = new ThreadAdd(addTest1);
ThreadAdd threadAdd2 = new ThreadAdd(addTest2);
threadAdd1.start();
threadAdd2.start();

我们使用synchronized修饰静态方法,

public class AddTest {
    private static int num = 0;

    public int getNum(){

        return num;
    }
    public static synchronized void addOne(String name){
        num++;
        System.out.println(name+":"+num);
    }
}

public class ThreadAdd extends Thread {

    private AddTest mAddTest;
    private String name;

    public ThreadAdd(AddTest addTest,String name){
        this.mAddTest = addTest;
        this.name = name;
    }

    @Override
    public void run() {
        super.run();
        for (int i = 0;i<10;i++) {
            try {
                Thread.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            mAddTest.addOne(name);
        }
        System.out.println(name + "------:"+mAddTest.getNum());
    }
}

public static void main(String[] args) {
        AddTest addTest1 = new AddTest();
        AddTest addTest2 = new AddTest();
        ThreadAdd threadAdd1 = new ThreadAdd(addTest1,"A");
        ThreadAdd threadAdd2 = new ThreadAdd(addTest2,"B");
        threadAdd1.start();
        threadAdd2.start();
    }

代码的执行结果如下:


image

尽管第一个执行完的线程打印的结果总是不确定的,但是最后一个线程的结果总是40000。因为这两个线程持有的是同一把锁,此时它们持有的锁不再是对象锁,而是类锁,也就是Class对象锁,这把锁不管当前有多少实例存在,都确保了只有一个线程可以放完这个类。

锁重入

关键字synchronized有用锁重入的功能,在使用synchronized时,当一个线程得到一个对象锁后,再次请求次对象锁是可以再次得到锁的(自己可以再次得到自己持有的锁)。这也使得:在一个synchronized方法/代码块中调用同一把锁保护的synchronized方法/代码块是可行的:

public class AddTest {
    private int num = 0;

    public int getNum(){
        return num;
    }
    public synchronized void addOne(){
        num++;
        cutOne();
    }
    public synchronized void cutOne(){
        num--;
    }
}

执行这段代码,获取的num值为0,在线程持有锁并执行addOne方法内部调用cutOne时,该线程并未释放锁,调用cutOne方法时,可再次获得锁。
可重入锁支持继承

public class Human {
    public synchronized void method(){

    }
}

public class Student extends Human {
    @Override
    public synchronized void method() {
        //调用父类的同步方法
        super.method();
    }
}

子类可以通过可重入锁低啊用父类的同步方法。
当同步方法或者代码块执行完毕的时候,锁就会被释放。而当线程执行代码时发生异常,锁也会被自动释放。
虽然锁重入支持继承,但是<u>同步不支持继承</u>,如上文中的代码:尽管父类Human的method方法是同步方法。但是子类Student必须使用synchronized修饰method方法,才能确保该它的method方法是同步方法。

synchronized方法的弊端

观察下面的代码:


class Run {
    public static void main(String[] args) {
        AddTest addTest = new AddTest();
        ThreadAdd threadAdd1 = new ThreadAdd(addTest,"A");
        ThreadAdd threadAdd2 = new ThreadAdd(addTest,"B");
        threadAdd1.start();
        threadAdd2.start();
    }
}

public class AddTest {
    private int num = 0;

    public int getNum(){
        return num;
    }
    public void addOne(){

        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        num++;
    }
}

public class ThreadAdd extends Thread {

    private AddTest mAddTest;
    private String name;
    public ThreadAdd(AddTest addTest,String name){
        this.mAddTest = addTest;
        this.name = name;
    }

    @Override
    public void run() {
        super.run();
        Long start = System.currentTimeMillis();
        for (int i = 0;i<100;i++) {
            mAddTest.addOne();
        }
System.out.println("ThreadAdd:" + mAddTest.getNum() + name + ":" + String.valueOf(System.currentTimeMillis() - start));
    }
}

打印他们的执行结果和时间:


image

这个代码是非线程安全的,将addOne方法改为同步方法:

public synchronized void addOne(){

        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        num++;
    }
image

此时线程变得安全了,但是代码执行的时间却增加了很多。

使用同步方法的策略可以简单确保线程安全,但是这种粗粒度的实现方式带来的代价是惨痛的。假如我们我个Service实现对某页面的访问量。但当我们将它设计成一个同步方法时,就使得每次只有一个线程可以访问它,这在高负载的情况下会使得程序的执行时间变得很长——因为所有的请求都必须排队执行。这完全背离了我们程序设计的初衷,而解决这个问题的方法就是同步代码块。

synchronized同步代码块

上文中讲述synchronized同步方法的弊端中,我们可以发现,在同步的时候,线程在Thread.sleep(10)时也是需要阻塞并同步执行的。而这块代码并不需要保证是安全,假如我们可以使用异步的方式进行这个等待操作,代码的执行效率就会有很大的提升。

实例锁

修改addOne方法:

public void addOne() {

        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (this) {
            num++;
        }
    }

执行结果为:

image

代码的执行效率有了显著的提高,因为线程等待的不再是同步的了,addOne方法不再是同步执行的了,任何线程都可以访问该方法,只有在进行num++操作时才需要同步执行。
在使用同步synchronized (this)代码块时:当一个线程访问该对象的一个同步代码块时,其它线程对同一对象实例中任何synchronized (this)同步代码块都将被阻塞。保护这些代码的所都是同一个,也就是当前类的一个实例对象。尝试使用不同实例,修改线程执行的代码:

AddTest addTest = new AddTest();
AddTest addTest2 = new AddTest();
ThreadAdd threadAdd1 = new ThreadAdd(addTest,"A");
ThreadAdd threadAdd2 = new ThreadAdd(addTest2,"B");
threadAdd1.start();
threadAdd2.start();

代码执行结果如下:

image

两个线程的执行没有任何干扰,各自执行这自己的操作。因为两个线程分别持有不同的对象,访问了不同实例对象的addOne方法,而方法中的同步代码块也被不同的实例对象作为锁保护着。
我们可以把任何的对象作为一个锁,修改上述代码:

public class AddTest {
    private int num = 0;
    private Object mObject = new Object();
    public int getNum() {
        return num;
    }
    public void addOne() {
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (mObject) {
            num++;
        }
    }
}

AddTest addTest = new AddTest();
ThreadAdd threadAdd1 = new ThreadAdd(addTest,"A");
ThreadAdd threadAdd2 = new ThreadAdd(addTest,"B");
threadAdd1.start();
threadAdd2.start();

执行结果如下:

image

mObject作为一个锁保护着num++操作。
尝试一些使用同一个锁去保护不同的实例对象:
首先看下面代码的执行结果:

Object o = new Object();
AddTest addTest = new AddTest(o);
AddTest addTest2 = new AddTest(o);
ThreadAdd threadAdd1 = new ThreadAdd(addTest,"A");
ThreadAdd threadAdd2 = new ThreadAdd(addTest2,"B");
threadAdd1.start();
threadAdd2.start();

public class AddTest {

    private Object mObject;
    private int    num = 0;

    public AddTest(Object object) {

        this.mObject = object;
    }

    public int getNum() {

        return num;
    }

    public void addOne() {

        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        num++;
        
    }
}

image

两个线程分别访问两个不同对象实例的方法,不存在多线程访问同一个对象实例的问题,记录代码执行时间。
接下来修改一下addOne的代码,使用mObject对它的代码进行保护:

public void addOne() {
        synchronized (mObject) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            num++;
        }
    }

执行结果如下:


image

仍然是各自线程访问自己各自的对象实例,但是执行时间却大幅上涨——这是因为两个线程持有同一把锁,当一个线程A持有该锁时,线程B无法访问用该锁保护的任何代码块。即使这段代码块和线程A没有任何关系,也不会被线程A访问。

Class锁

接着上述代码,继续修改addOne方法:

public void addOne() {
        synchronized (AddTest.class) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            num++;
        }
    }

其它代码保持不变,仅仅将synchronized(mObject)synchronized (AddTest.class)替换,会产生同样效果。因为用来保护代码块的是同一把锁——类锁。锁是加持在类上的,用synchronized static或者synchronized(class)方法使用的锁都是类锁,因为class和静态方法在系统中只会产生一份,所以在单系统环境中使用类锁是线程安全的。
类锁和上面的对象锁唯一不同的区别是,类锁只有一把,无论你创建多少实例对象,它们都公用一把锁。而对象锁你可以动态的使用不同的锁,如果你能确保所有的同步都用同一个对象锁,那么对象锁也能实现类锁的功能。

名称 描述
对象锁 synchronized 修饰非静态的方法和synchronized(this)都是使用的对象锁,一个系统可以有多个对象实例,所以使用对象锁不是线程安全的,除非保证一个系统该类型的对象只会创建一个(通常使用单例模式)才能保证线程安全;
类锁 锁是加持在类上的,用synchronized static 或者synchronized(class)方法使用的锁都是类锁,因为class和静态方法在系统中只会产生一份,所以在单系统环境中使用类锁是线程安全的;

String锁!!!

由于在JVM中具有String常量池缓存的功能,因此相同字面量是同一个锁。

总结

分类 被锁的对象 示例代码
普通方法 当前实例对象 public synchronized void method() {
}
静态方法 当前类的Class对象 public static synchronized void methodStatic() {
}
分类 被锁的对象 示例代码
普通实例对象 当前对象实例 synchronized (this){
}
类对象 当前类的Class对象 synchronized (Student.class){
}
任意对象 当前类的Class对象 String lock = new String();
synchronized (lock){
}
  • 可重入锁支持继承
  • 同步不具有继承性
  • 调用synchronized修饰的方法时,线程一定是排队运行的
  • 当线程执行代码时发生异常,锁会被自动释放
  • 线程间同时访问同一个锁的多个同步代码的执行顺序不定
  • 当一个线程进入同步方法时,其他线程可以正常访问其他非同步方法
  • 多个对象多个锁不会存在阻塞,多个对象一个锁会存在线程阻塞

最后

水平有限,码字不易。如有纰漏,望指正!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容