(Java高级教程)第一章Java多线程基础-第四节:synchronized关键字(监视器锁monitor lock)和volatile关键字

本文详细讲解了Java中的synchronized关键字,包括它的互斥性、可重入性以及注意事项。同时,深入剖析了锁对象的概念,并举例说明了不同锁对象的使用情况。此外,还介绍了volatile关键字的作用,它确保内存可见性但不保证原子性。最后,文章对比了synchronized和volatile在保证线程安全方面的差异。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一:synchronized关键字(监视器锁monitor lock)

(1)synchronized简介

synchronized简介:其中文意思为同步,所以也称之为同步锁。其作用是保证在同一时刻被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。synchronized是Java中解决并发问题的一种最常用方法,也是最简单的一种方法

对于前文中的那个线程不安全代码,我们可以使用synchronized修饰decrease方法

class Counter{
    public int tickets = 100000;
    public synchronized void decrease(){
        for(int i = 0; i < 50000; i++) {
            tickets--;
        }
    }
}

public class TestDemo {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        //下面两个线程,每个线程都会counter进行5W次自减
        //正确结果理应为0

        Thread thread1 = new Thread(){
            @Override
            public void run(){
                counter.decrease();
            }
        };

        Thread thread2 = new Thread(){
            @Override
            public void run(){
                counter.decrease();
            }
        };

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("counter: " + counter.tickets);

    }
}

可以看到,此时counter.tickets正确自减为0(多次运行结果仍为0)

在这里插入图片描述

(2)synchronized特性

①:互斥

互斥synchronized起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同一个对象的synchronized时就会阻塞等待

  • 加锁:进入synchronized修饰的代码块
  • 解锁:退出synchronized修饰的代码块

在这里插入图片描述

②:可重入

可重入synchronized同步块对同一条线程来说时可重入的,不会存在自己把自己锁死的情况

所谓自己把自己锁死,是指一个线程没有释放锁,然后又尝试再次加锁。对于不可重入锁来说,第二次加锁的时候需要等待第一次加锁释放,但释放操作也必须由该线程来完成,所以这就造成了矛盾,形成了死锁

public class TestDemo2 {
    static class Counter{
        //第一次加锁,成功
        public synchronized void decrease(){
            //第二次加锁,锁已经被占用,阻塞等待
            synchronized (this){

            }
        }
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread = new Thread(){
            @Override
            public void run(){
                counter.decrease();
            }
        };

        thread.start():
    }
}

但好在我们的synchronized可重入锁,不会有上述问题发生。这因为在synchronized内部包含了线程持有者计数器这两个信息

  • 如果某个线程加锁的时候,发现锁已经被占用,而恰好占用者竟是自己,那么仍然可以继续获取到锁,并让计数器自增
  • 只有当计数器减为0时才算真正释放了锁

(3)synchronized注意事项

①:加锁时一定要清楚你需要加锁的代码是哪一部分,否则代码逻辑可能会出现问题,凡是不被synchronized修饰的代码都是并发执行

  • 例如下面的例子中,如果把for循环直接写在被synchronized修饰的decrease方法中,那么这就不是多线程了,而是单线程
class Counter{
    public int tickets = 100000;
    public synchronized void decrease(Thread thread){
        for(int i = 0; i < 50000; i++) {
            System.out.println(thread.getName() + "执行中" + "第" + i + "次自减");
            tickets--;
        }
    }
}

public class TestDemo {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        //下面两个线程,每个线程都会counter进行5W次自减
        //正确结果理应为0

        Thread thread1 = new Thread("thread1"){
            @Override
            public void run(){
                counter.decrease(this);
            }
        };

        Thread thread2 = new Thread("thread2"){
            @Override
            public void run(){
                counter.decrease(this);
            }
        };

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println("counter: " + counter.tickets);

    }
}

在这里插入图片描述

②:锁住的代码越多,那么锁的粒度就越大,反之,锁的粒度越小

③:不是说加了锁就一定能保证线程安全,只有正确的加锁方式才能保证线程安全

④:Java多线程的封装性要比C++强一点,我们可以做以对比。如下是C++多线程代码,和上述代码逻辑基本一致,不同的是生成了4个线程,轮流进入函数减少tickets,其中pthread_mutex_lockpthread_mutex_uolock分别就是加锁和释放锁

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>


int tickets=1000;

pthread_mutex_t lock;//申请一把锁

void scarmble_tickets(void* arg)
{
  long int ID=(long int)arg;//线程ID
  while(1)//多个线程循环抢票
  {
    pthread_mutex_lock(&lock);//那个线程先到,谁就先锁定资源
    if(tickets>0)
    {
      usleep(1000);
      printf("线程%ld号抢到了一张票,现在还有%d张票\n",ID,tickets);
      tickets--;
      pthread_mutex_unlock(&lock);//抢到票就解放资源
    }
    else 
    {
      pthread_mutex_unlock(&lock);//如果没有抢到也要释放资源,否则线程直接退出,其他线程无法加锁
      break;
    }
  }

}


int main()
{
  int i=0;
  pthread_t tid[4];//4个线程ID
  pthread_mutex_init(&lock,NULL);//初始化锁
  for(i=0;i<4;i++)
  {
    pthread_create(tid+1,NULL,scarmble_tickets,(void*)i);//创建4个线程
  }

  for(i=0;i<4;i++)
  {
    pthread_join(tid+1,NULL);//线程等待
  }
  pthread_mutex_destroy(&lock);//销毁锁资源
  return 0;
}


在这里插入图片描述

⑤:可以使用synchronized的地方有,如下

  • 修饰普通方法(此时锁对象相当于this:锁的是counter对象
  • 修饰静态方法(此时锁对象相当于类对象):锁的是counter类的对象
  • 修饰代码块:需要明确指定锁的是哪个对象
1:修饰普通方法
public synchronized void decrease(){
    for(int i = 0; i < 50000; i++) {
        tickets--;
    }
}

2:修饰静态方法
public synchronized static void decrease(){
    for(int i = 0; i < 50000; i++) {
        tickets--;
    }
}

3:修饰代码块
public synchronized void decrease(){
        synchronized (this){
            tickets--;
        }
    }

(4)synchronized深刻理解之锁对象

synchronized深刻理解之锁对象:可以看出,C++在实现并发时全局有一把锁

  • 以上面C++代码为例,并不一定只有一把,共享资源有多少这把锁一般就有多少

然后每个线程在进入需要并发执行的代码时会持有锁,进行锁竞争。而在Java中,任意对象都可以作为锁对象,所以无需关心这个锁对象究竟是谁,只关心它们是否锁同一对象,只要锁的是同一对象,那么就有锁竞争。在实际情况中,会有很多种写法,这些写法有的可以形成锁竞争,有的则不可以,现在讨论如下,依次帮助大家理解这部分概念

写法1:针对同一对象counter加锁,可以形成锁竞争

  • 此时this指的就是counter对象
class Counter{
    public int tickets = 100000;
    public void decrease(){
        synchronized (this){
            tickets--;
        }
    }
}

Thread thread1 = new Thread("thread1"){
            @Override
            public void run(){
                for(int i = 0; i < 50000; i++) {
                    counter.decrease();
                }
            }
        };

        Thread thread2 = new Thread("thread2"){
            @Override
            public void run(){
                for(int i = 0; i < 50000; i++) {
                    counter.decrease();
                }
            }
        };

写法2:针对不同对象counter1counter2加锁,无法形成锁竞争

  • 此时this分别指的是counter1counter2对象
class Counter{
    public int tickets = 100000;
    public void decrease(){
        synchronized (this){
            tickets--;
        }
    }
}
private static Counter counter = new Counter();
private static Counter counter2 = new Counter();
	Thread thread1 = new Thread("thread1"){
      @Override
      public void run(){
          for(int i = 0; i < 50000; i++) {
              counter.decrease();
          }
      }
  };
	
	Thread thread2 = new Thread("thread2"){
	   @Override
	   public void run(){
	       for(int i = 0; i < 50000; i++) {
	           counter2.decrease();
	       }
	   }
};
    

写法3:使用一个专门的locker对象作为锁对象,this改为locker,然后针对同一counter加锁。此时由于counter是同一个,所以locker也是一样的,因此可以形成锁竞争

  • 注意: 在这个代码例子中,当然可以形成锁竞争。但换在其他情形中不一定,具体是否能形成锁竞争,还得看后续代码实现
class Counter{
    public int tickets = 100000;
    public Object locker = new Object();
    public void decrease(){
        synchronized (locker){
            tickets--;
        }
    }
}

Thread thread1 = new Thread("thread1"){
            @Override
            public void run(){
                for(int i = 0; i < 50000; i++) {
                    counter.decrease();
                }
            }
        };

        Thread thread2 = new Thread("thread2"){
            @Override
            public void run(){
                for(int i = 0; i < 50000; i++) {
                    counter.decrease();
                }
            }
        };

写法4:结合写法2和写法3,自然无法形成锁竞争,因为是不同的对象

写法5:将locker设为static,此时locker为静态成员,由于静态成员只有一份,所以即便有两个不同的对象countercounter2,也是能够形成锁竞争的

class Counter{
    public int tickets = 100000;
    static private Object locker = new Object();
    public void decrease(){
        synchronized (locker){
            tickets--;
        }
    }
}


Thread thread1 = new Thread("thread1"){
            @Override
            public void run(){
                for(int i = 0; i < 50000; i++) {
                    counter.decrease();
                }
            }
        };

        Thread thread2 = new Thread("thread2"){
            @Override
            public void run(){
                for(int i = 0; i < 50000; i++) {
                    counter2.decrease();
                }
            }
        };

写法6:将Counter.class即类对象作为锁对象,由于类对象在JVM中只有一个,所以可以形成锁竞争

class Counter{
    public int tickets = 100000;
    public void decrease(){
        synchronized (Counter.class){
            tickets--;
        }
    }
}

Thread thread1 = new Thread("thread1"){
          @Override
          public void run(){
              for(int i = 0; i < 50000; i++) {
                  counter.decrease();
              }
          }
      };

Thread thread2 = new Thread("thread2"){
          @Override
          public void run(){
              for(int i = 0; i < 50000; i++) {
                  counter2.decrease();
              }
          }
      };

①:直接修饰普通方法

  • 此时锁对象就是this
public synchronized void decrease(){
    for(int i = 0; i < 50000; i++) {
        tickets--;
    }
}

②:修饰代码块

  • 此时synchronized里面的锁对象为this,所以谁调用decrease,就针对谁加锁。这里针对是counter对象,所以两个线程执行到这里时就会出现互斥
public synchronized void decrease(){
        synchronized (this){
            tickets--;
        }
    }

在这里插入图片描述

  • 相反,像下面这种情况,这两个线程在针对不同对象加锁,所以就不会出现锁竞争

在这里插入图片描述

(5)Java标准库中的线程安全类

Java标准库中的线程安全类:多线程环境下,Java标准库中很多都是线程不安全的,所以在使用时要慎重

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

以下是线程安全的,使用锁机制

  • Vector
  • HashTable
  • ConcurrentHashMap
  • StringBuffer
  • String(虽然没有加锁,但是它不涉及修改,所以被视为是线程安全的)

二:volatile关键字

(1)volatile概念和作用

  • 由于内存的访问速度远不及CPU的处理速度,所以为了提高机器的整体性能,在硬件上引入了高速缓冲Cache,加速对内存的访问。除了硬件级别的优化外,编译器也通常会进行优化,比如说将内存变量缓冲到寄存器或调整指令顺序充分利用CPU指令流水线

volatile关键字:被volatile修饰的变量,能够保证“内存可见性”。其作用主要是用来避免数据不一致的情形发生。这是因为访问寄存器要比访问内存单元快的多,所以编译器一般都会作减少存取内存的优化,也就是读取数据时更加倾向于读取寄存器中的数据,这就有可能读取到脏数据

如下代码中,有一个类Counter,内有一变量counter,初始值为0。线程thread1counter始终为0的情况下会一直死循环,直到counter不为0;线程thread2则随时接受输入,如果输入一个不为0的整数,那么thread1就会结束循环并停止

import java.util.Scanner;

public class TestDmmo3 {
    static class Counter{
        public int count = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread1 = new Thread(){
            @Override
            public void run(){
                while(counter.count == 0){

                }
                System.out.println("线程1执行结束");
            }
        };
        thread1.start();

        Thread thread2 = new Thread(){
            @Override
            public void run(){
                System.out.println("(线程2)输入一个整数:");
                Scanner scanner = new Scanner(System.in);
                counter.count = scanner.nextInt();
            }
        };
        thread2.start();
    }
}

但程序运行后,无论输入的整数是多少,线thread1始终无法结束循环。其原因就是上文中所提到,编译器做了优化,它认为counter是一个始终不变化的量,就会直接将其移入缓存或寄存器以加快读取速度

在这里插入图片描述

解决方法就是使用volatile关键字修饰,让其强制从内存读取

static class Counter{
    public volatile int count = 0;
}

在这里插入图片描述

(2)volatile不保证原子性

volatile不保证原子性:volatile和synchronized有着本质区别。synchronized能够保证原子性,而volatile不能保证,只能保证内存可见性

  • 针对一个线程读另一个线程写这种场景,使用volatile比较合适
  • 针对两个线程修改这种场景,volatile无能为力

如下代码,可以看到即便使用volatile仍然无法保证原子性

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

快乐江湖

创作不易,感谢支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值