Java线程学习笔记之线程同步

线程间共享数据

要使多个线程在一个程序中有用,它们必须有某种方法可以互相通信或共享它们的结果。而让线程共享其结果的最简单方法是使用共享变量
线程与进程有许多共同点,不同的是线程与同一进程中的其它线程共享相同的进程上下文,包括内存。这非常便利,但也有重大责任。只要访问共享变量(静态或实例字段),线程就可以方便地互相交换数据,但线程还必须确保它们以受控的方式访问共享变量,以免它们互相干扰对方的更改。
为了确保可以在线程之间以受控方式共享数据,Java 语言提供了两个关键字:synchronizedvolatile
Synchronized (同步)有两个重要含义:它确保了一次只有一个线程可以执行代码的受保护部分(互斥,mutual exclusion 或者说 mutex),而且它确保了一个线程更改的数据对于其它线程是可见的(更改的可见性)。
同步让我们可以定义必须原子地运行的代码块,这样对于其他线程而言,它们要么都执行,要么都不执行。
要说明线程同步问题首先要说明Java线程的两个特性,可见性有序性。多个线程之间是不能直接传递数据交互的,它们之间的交互只能通过共享变量来实现。拿上篇博文中的例子来说明,在多个线程之间共享了Count类的一个对象,这个对象是被创建在主内存(堆内存)中,每个线程都有自己的工作内存(线程栈),工作内存存储了主内存Count对象的一个副本,当线程操作Count对象时,首先从主内存复制Count对象到工作内存中,然后执行代码count.count(),改变了num值,最后用工作内存Count刷新主内存Count。当一个对象在多个内存中都存在副本时,如果一个内存修改了共享变量,其它线程也应该能够看到被修改后的值,此为可见性。多个线程执行时,CPU对线程的调度是随机的,我们不知道当前程序被执行到哪步就切换到了下一个线程,一个最经典的例子就是银行汇款问题,一个银行账户存款100,这时一个人从该账户取10元,同时另一个人向该账户汇10元,那么余额应该还是100。那么此时可能发生这种情况,A线程负责取款,B线程负责汇款,A从主内存读到100,B从主内存读到100,A执行减10操作,并将数据刷新到主内存,这时主内存数据100-10=90,而B内存执行加10操作,并将数据刷新到主内存,最后主内存数据100+10=110,显然这是一个严重的问题,我们要保证A线程和B线程有序执行,先取款后汇款或者先汇款后取款,此为有序性。本文讲述了JDK5.0之前传统线程的同步方式.
下面同样用代码来展示一下线程同步问题。
创建两个线程,执行同一个对象的输出方法:

public class TraditionalThreadSynchronized {
    public static void main(String[] args) {
        final Outputter output = new Outputter();
        new Thread() {
            public void run() {
                output.output("zhangsan");
            }
        }.start();      
        new Thread() {
            public void run() {
                output.output("lisi");
            }
        }.start();
    }
}
class Outputter {
    public void output(String name) {
        // TODO 为了保证对name的输出不是一个原子操作,这里逐个输出name的每个字符
        for(int i = 0; i < name.length(); i++) {
            System.out.print(name.charAt(i));
            // Thread.sleep(10);
        }
    }
}

结果:

zhlainsigsan  

显然输出的字符串被打乱了,我们期望的输出结果是zhangsanlisi,这就是线程同步问题,我们希望output方法被一个线程完整的执行完之后再切换到下一个线程,Java中使用synchronized保证一段代码在多线程执行时是互斥的,有两种用法:
1. 使用synchronized将需要互斥的代码包含起来,并上一把锁。

    {  
        synchronized (this) {  
            for(int i = 0; i < name.length(); i++) {  
                System.out.print(name.charAt(i));  
            }  
        }  
    }  

每次进入output方法都会创建一个新的lock,这个锁显然每个线程都会创建,没有意义。
2. 将synchronized加在需要互斥的方法上。

    public synchronized void output(String name) {  
        // TODO 线程输出方法  
        for(int i = 0; i < name.length(); i++) {  
            System.out.print(name.charAt(i));  
        }  
    }  

这种方式就相当于用this锁住整个方法内的代码块。
同步可以让我们确保线程看到一致的内存视图。
每个锁对象(JLS中叫monitor)都有两个队列,一个是就绪队列,一个是阻塞队列,就绪队列存储了将要获得锁的线程,阻塞队列存储了被阻塞的线程,当一个线程被唤醒(notify)后,才会进入到就绪队列,等待CPU的调度,反之,当一个线程被wait后,就会进入阻塞队列,等待下一次被唤醒,这个涉及到线程间的通信,下一篇博文会说明。看我们的例子,当第一个线程执行输出方法时,获得同步锁,执行输出方法,恰好此时第二个线程也要执行输出方法,但发现同步锁没有被释放,第二个线程就会进入就绪队列,等待锁被释放。 一个线程执行互斥代码过程如下:

    1. 获得同步锁;

    2. 清空工作内存;

    3. 从主内存拷贝对象副本到工作内存;

    4. 执行代码(计算或者输出等);

    5. 刷新主内存数据;

    6. 释放同步锁。

Volatile 比同步更简单,只适合于控制对基本变量(整数、布尔变量等)的单个实例的访问。当一个变量被声明成 volatile,任何对该变量的写操作都会绕过高速缓存,直接写入主内存,而任何对该变量的读取也都绕过高速缓存,直接取自主内存。这表示所有线程在任何时候看到的 volatile 变量值都相同。
Volatile 对于确保每个线程看到最新的变量值非常有用,但有时我们需要保护比较大的代码片段,如涉及更新多个变量的片段。

同步使用监控器(monitor)或锁的概念,以协调对特定代码块的访问。

每个 Java 对象都有一个相关的锁。同一时间只能有一个线程持有 Java 锁。当线程进入 synchronized 代码块时,线程会阻塞并等待,直到锁可用,当它可用时,就会获得这个锁,然后执行代码块。当控制退出受保护的代码块时,即到达了代码块末尾或者抛出了没有在 synchronized 块中捕获的异常时,它就会释放该锁。

这样,每次只有一个线程可以执行受给定监控器保护的代码块。从其它线程的角度看,该代码块可以看作是原子的,它要么全部执行,要么根本不执行。

同步的方法

创建 synchronized 块的最简单方法是将方法声明成 synchronized。这表示在进入方法主体之前,调用者必须获得锁:

public class Point {
  public synchronized void setXY(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

对于普通的 synchronized方法,这个锁是一个对象,将针对它调用方法。对于静态 synchronized 方法,这个锁是与 Class 对象相关的监控器,在该对象中声明了方法。

仅仅因为 setXY() 被声明成 synchronized 并不表示两个不同的线程不能同时执行 setXY(),只要它们调用不同的 Point 实例的 setXY() 就可同时执行。对于一个 Point 实例,一次只能有一个线程执行 setXY(),或 Point 的任何其它 synchronized 方法。

同步的块

synchronized 块的语法比 synchronized 方法稍微复杂一点,因为还需要显式地指定锁要保护哪个块。Point 的以下版本等价于前一页中显示的版本:

public class Point {
  public void setXY(int x, int y) {
    synchronized (this) {
      this.x = x;
      this.y = y;
    }
  }
}

使用 this 引用作为锁很常见,但这并不是必需的。这表示该代码块将与这个类中的 synchronized 方法使用同一个锁。
访问局部(基于堆栈的)变量从来不需要受到保护,因为它们只能被自己所属的线程访问。

大多数类没有同步

因为同步会带来小小的性能损失,大多数通用类,如 java.util 中的 Collection 类,不在内部使用同步。这表示在没有附加同步的情况下,不能在多个线程中使用诸如 HashMap 这样的类。 不过,Collections 类提供了一组便利的用于 List、Map 和 Set 接口的封装器。您可以用 Collections.synchronizedMap 封装 Map,它将确保所有对该映射的访问都被正确同步。

什么时候必须同步

可见性同步的基本规则是在以下情况中必须同步:

读取上一次可能是由另一个线程写入的变量
写入下一次可能由另一个线程读取的变量

什么时候不需要同步

在某些情况中,您不必用同步来将数据从一个线程传递到另一个,因为 JVM 已经隐含地为您执行同步。这些情况包括:

由静态初始化器(在静态字段上或 static{} 块中的初始化器)初始化数据时
访问 final 字段时
在创建线程之前创建对象时
线程可以看见它将要处理的对象时

死锁

只要您拥有多个进程,而且它们要争用对多个锁的独占访问,那么就有可能发生死锁。如果有一组进程或线程,其中每个都在等待一个只有其它进程或线程才可以执行的操作,那么就称它们被死锁了。

最常见的死锁形式是当线程 1 持有对象 A 上的锁,而且正在等待与 B 上的锁,而线程 2 持有对象 B 上的锁,却正在等待对象 A 上的锁。这两个线程永远都不会获得第二个锁,或者释放第一个锁。它们只会永远等待下去。

要避免死锁,应该确保在获取多个锁时,在所有的线程中都以相同的顺序获取锁。

关于性能

机性地优化一个也许最终根本不会成为性能问题的代码路径 ― 以程序正确性为代价 ― 是一桩赔本的生意。

同步准则

  • 使代码块保持简短。Synchronized 块应该简短 ― 在保证相关数据操作的完整性的同时,尽量简短。把不随线程变化的预处理和后处理移出 synchronized 块。
  • 不要阻塞。 不要在 synchronized 块或方法中调用可能引起阻塞的方法,如 InputStream.read()。
  • 在持有锁的时候,不要对其它对象调用方法。这听起来可能有些极端,但它消除了最常见的死锁源头。

其他线程API

Object 类定义了 wait()、notify() 和 notifyAll() 方法。要执行这些方法,必须拥有相关对象的锁。

Wait() 会让调用线程休眠,直到用 Thread.interrupt() 中断它、过了指定的时间、或者另一个线程用 notify() 或 notifyAll() 唤醒它。

当对某个对象调用 notify() 时,如果有任何线程正在通过 wait() 等待该对象,那么就会唤醒其中一个线程。当对某个对象调用 notifyAll() 时,会唤醒所有正在等待该对象的线程。

这些方法是更复杂的锁定、排队和并发性代码的构件。但是,notify() 和 notifyAll() 的使用很复杂。尤其是,使用 notify() 来代替 notifyAll() 是有风险的。除非您确实知道正在做什么,否则就使用 notifyAll()。

与其使用 wait() 和 notify() 来编写您自己的调度程序、线程池、队列和锁,倒不如使用 util.concurrent 包

本文为学习笔记,学习资源主要来自以下出处:
Java线程简介
线程同步synchronized和volatile

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值