Java 并发编程(二)线程同步

本贴最后更新于 1981 天前,其中的信息可能已经物是人非

synchronized 关键字

1、对某个对象加锁

public class T { private int count = 10; private final Object lock = new Object(); public void m() { synchronized (lock) { // 任何线程要执行下面的代码,都必须先拿到lock锁,锁信息记录在堆内存对象中的,不是在栈引用中 // 如果lock已经被锁定,其他线程再进入时,就会进行阻塞等待 // 所以 synchronized 是互斥锁 count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } // 当代码块执行完毕后,锁就会被释放,然后被其他线程获取 } }

2、每次使用锁都要 newObject,比较麻烦,可以使用 this 代替 object 锁

public class T { private int count = 10; public void m() { synchronized (this) { // 任何线程要执行下面的代码,必须先拿到this锁 // synchronized 锁定的不是代码块,而是 this 对象 count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } } }

3、若整个方法内所有代码都被 synchronized 修饰,则可以使 synchronized 关键字修饰整个方法.

public class T { private int count = 10; public synchronized void m() { // 等同于 synchronized (this) { count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } }

4、若 synchronized 关键字锁定静态方法,等价于锁定 T.class 对象

public class T { private static int count = 10; public static synchronized void m() { // 等同于 synchronized (package.T.class) { count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } public static void mm(){ synchronized (T.class){//这里不可以写this count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } } }

synchronized 关键字的使用

使用 synchronized 关键字修饰代码块,保证 synchronized 代码块内操作的原子性

public class T implements Runnable{ private int count = 10; @Override public /*synchronized*/ void run() { count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } public static void main(String[] args) { T t = new T(); for (int i = 0; i < 5; i++) { new Thread(t,"THREAD").start(); } } }

不加 synchronized 关键字,程序输出如下: 因为不保证原子性,每个线程在执行自减操作和输出操作之间都可能被其它线程打断.

Thread-0 count = 7 Thread-4 count = 5 Thread-3 count = 6 Thread-2 count = 7 Thread-1 count = 7

加上 synchronized 关键字,程序输出如下:

Thread-0 count = 9 Thread-4 count = 8 Thread-3 count = 7 Thread-2 count = 6 Thread-1 count = 5

同步方法和非同步方法可以同时调用

public class T { public synchronized void m1() { System.out.println(Thread.currentThread().getName() + " m1 start"); try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " m1 end"); } public void m2() { System.out.println(Thread.currentThread().getName() + " m2 start"); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " m2 end"); } public static void main(String[] args) { T t = new T(); new Thread(t::m1).start(); new Thread(t::m2).start(); } }

程序输出如下:

Thread-0 m1 start Thread-1 m2 start Thread-1 m2 end Thread-0 m1 end

对业务写方法加锁,而对业务读方法不加锁,容易出现脏读问题

因为在执行写的过程中,读操作没有加锁,所以读会读取到写未改完的脏数据。所以需要给读写都加锁

public class Account { /** * 银行账户名称 */ String name; /** * 银行账余额 */ double balance; public synchronized void set(String name, double balance) { this.name = name; try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } this.balance = balance; } public /*synchronized*/ double getBalance() { return this.balance; } public static void main(String[] args) { Account a = new Account(); new Thread(() -> a.set("张三", 100.0)).start(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(a.getBalance()); // 0.0 加锁后100.0 try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(a.getBalance()); // 100.0 加锁后100.0 } }

synchronized 是可重入锁

即一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,再次申请时仍然会得到该对象的锁

public class T { synchronized void m1() { System.out.println("m1 start "); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } m2(); } synchronized void m2() { try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(" m2"); // 这句话会打印,调用m2时,不会发生死锁 } }

子类调用父类的同步方法,也是可重入的

public class T { synchronized void m() { System.out.println("m start "); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("m end "); } public static void main(String[] args) { new TT().m(); } } class TT extends T { @Override synchronized void m() { System.out.println("child m start "); super.m(); System.out.println("child m end "); } }

程序运行结果如下:

child m start m start m end child m end

synchronized 代码块中,如果发生异常,锁会被释放

在并发处理过程中,有异常要多加小心,不然可能发生数据不一致的情况。
比如,在一个 web app 处理过程中,多个 servlet 线程共同访问同一资源,这时如果异常处理不合适,第一个线程抛出异常,其他线程就会进入同步代码区,有可能访问到异常产生的数据。
因此要非常小心处理同步业务逻辑中的异常。

public class T { int count = 0; synchronized void m() { System.out.println(Thread.currentThread().getName() + " start"); while (true) { count++; System.out.println(Thread.currentThread().getName() + " count=" + count); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } if (count == 5) { // 当count == 5 时,synchronized代码块会抛出异常 int i = 1 / 0; } } } public static void main(String[] args) { T t = new T(); Runnable r = new Runnable() { @Override public void run() { t.m(); } }; new Thread(r, "t1").start(); // 执行到第5秒时,抛出 ArithmeticException // 如果抛出异常后,t2 会继续执行,就代表t2拿到了锁,即t1在抛出异常后释放了锁 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(r, "t2").start(); } }

程序运行结果如下:

t1 start t1 count=1 t1 count=2 t1 count=3 t1 count=4 t1 count=5 t2 start Exception in thread "t1" java.lang.ArithmeticException: / by zero t2 count=6 at c_011.T.m(T.java:29) at c_011.T$1.run(T.java:39) at java.lang.Thread.run(Thread.java:748) t2 count=7 t2 count=8 t2 count=9 t2 count=10

synchronized 锁住的是堆中 o 对象的实例,而不是 o 对象的引用,因为 synchronized 是针对堆中 o 对象的实例上进行计数

  1. 若在程序运行过程中,引用 o 指向对象的属性发生改变,锁状态不变.
  2. 若在程序运行过程中,引用 o 指向的对象发生改变,则锁状态改变,原本抢到的锁作废,线程会去抢新锁.

因此实际编程中常将锁对象的引用用 final 修饰,保证其指向的锁对象不发生改变.(final 修饰引用时,该引用所指向的属性可以改变,但该引用不能再指向其他对象)

public class T { Object o = new Object(); // 该方法锁住的o对象引用没有被设为final void m() { synchronized (o) { while (true) { System.out.println(Thread.currentThread().getName() + "正在运行"); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } } } public static void main(String[] args) { T t = new T(); new Thread(t::m, "线程1").start(); // 在这里让程序睡一会儿,保证两个线程得到的o对象不同 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } Thread thread2 = new Thread(t::m, "线程2"); // 改变锁引用,使得线程2也有机会运行,否则一直都是线程1运行 t.o = new Object(); thread2.start(); } }

程序输出如下,看到主线程睡了 3 秒之后,线程1线程2 交替运行,他们各自抢到了不同的锁.

线程1正在运行 线程1正在运行 线程1正在运行 线程2正在运行 线程1正在运行 线程2正在运行 线程1正在运行 线程2正在运行 线程1正在运行 线程2正在运行 ...

不要以字符串常量作为锁定对象: 因为字符串常量池的存在,两个不同的字符串引用可能指向同一字符串对象

public class T { // 两个字符串常量,作为两同步方法的锁 String s1 = "Hello"; String s2 = "Hello"; // 同步m1方法以s1为锁 void m1() { synchronized (s1) { while (true) { System.out.println(Thread.currentThread().getName() + ":m1 is running"); } } } // 同步m2方法以s2为锁 void m2() { synchronized (s2) { while (true) { System.out.println(Thread.currentThread().getName() + ":m1 is running"); } } } public static void main(String[] args) { T t = new T(); // 输出两个锁的哈希码 System.out.println(t.s1.hashCode()); System.out.println(t.s2.hashCode()); new Thread(t::m1, "线程1").start(); new Thread(t::m2, "线程2").start(); } }

程序执行结果如下,我们发现两个字符串常量指向的是同一对象,且有一个线程永远得不到锁. 若我们的程序与某个库使用了同一个字符串对象作为锁,就会出现难以发现的 bug.

69609650 69609650 线程1:m1 is running 线程1:m1 is running 线程1:m1 is running 线程1:m1 is running 线程1:m1 is running 线程1:m1 is running

synchronized 方法和非 synchronized 方法可以同时执行,因为非 synchronized 方法不需要抢这把锁

volatile 关键字

volatile 关键字向编译器声明该变量是易变的,每次对 volatile 关键字的修改会通知给所有相关进程.

  1. 要理解 volatile 关键字的作用,要先理解 Java 内存模型 JMM
  • 在 JMM 中,所有对象以及信息都存放在主内存中(包含堆,栈),而每个线程在 CPU 中都有自己的独立空间,存储了需要用到的变量的副本.
  • 线程对共享变量的操作,都会先在自己 CPU 中的工作内存中进行,然后再同步给主内存.若不加 volatile 关键字修饰,每个线程都有可能从自己 CPU 中的工作内存读取内存;而加以 volatile 关键字修饰后,每个线程对该变量进行修改后都会马上通知给所有进程.
public class T { /*volatile*/ boolean running = true; // 若无volatile关键字修饰,则变量running难以在每个线程之间共享,对running变量的修改自然不能终止线程 // 可以通过将running变量设为false来终止m()方法 void m() { System.out.println("m start"); while (running) { // 死循环 } System.out.println("m end"); } public static void main(String[] args) { T t = new T(); new Thread(t::m, "t1").start(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } // 将running变量设为false,观察线程是否被终止 t.running = false; } }

我们发现,若不对 running 变量加以 volatile 修饰,则对 running 变量的修改不能终止子线程,说明在主线程中对 running 的修改对子线程不可见.

有趣的是,若在 while 死循环体中加入一些语句之后,可见性问题可能会消失,这是因为加入语句后,CPU 就可能会出现空闲,并同步主内存中的内容到工作内存,但这是不确定的,因此在这种情况下还是尽量要加上 volatile

  1. volatile 只能保证可见性,但不能保证原子性. volatile 不能解决多个线程同时修改一个变量带来的线程安全问题.
public class T { volatile int count = 0; /*AtomicInteger count = new AtomicInteger(0);*/ /*synchronized*/ void m() { for (int i = 0; i < 10000; i++) { count++; /*count.incrementAndGet();*/ } } public static void main(String[] args) { // 创建一个10个线程的list,执行任务皆是 m方法 T t = new T(); List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 10; i++) { threads.add(new Thread(t::m, "t-" + i)); } // 启动这10个线程 threads.forEach(Thread::start); // join 到主线程,防止主线程先行结束 for (Thread thread : threads) { try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } // 10个线程,每个线程执行10000次,结果应为 100000 System.out.println(t.count); } }

运行该程序,我们发现最终变量 t.count 并非如我们所预计的那样为 100000,而是小于 100000(当然,若去掉 volatile 修饰,最终 t.count 会更小).这说明 volatile 并不能保证对变量操作的原子性.

要保证多线程操作同一变量的原子性,有如下两种方法:

  1. 在方法上加 synchronized 修饰,synchronized 既保证可见性,又保证原子性.但 synchronized 效率最低.
  2. 使用 AtomicInteger 代替 int 类型(AtomicXXX 类可以用来替代基本数据类型,其支持一些原子操作).

综上所述,volatile 保证对被修饰变量的修改对于其他相关线程是可见的,即保证了可见性;但 volatile 并不能解决多个线程同时修改同一变量带来的线程安全问题,即不能保证原子性. 因此,只有在满足以下两个条件的情况下 volatile 才能保证解决线程的安全问题:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

  2. 变量不需要与其他状态变量共同参与不变约束

  • 并发
    75 引用 • 73 回帖 • 1 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...