内容来自公众号“我们都是小青蛙”,此文章仅作为学习笔记
前提:
进程中的各种资源,比如内存和I/O,在代码中以变量的形式展现,而这些变量在多线程间是共享、可变的,共享意味着这个变量可以被多个线程同时访问,可变意味着变量的值可能被访问它的线程修改。
共享变量的含义:
并不是所有的内存变量都可以被多个线程共享,在一个线程调用一个方法的时候,会在栈内存上为局部变量以及方法参数申请一些内存,在方法调用结束的时候,这些内存便被释放掉。不同线程调用同一个方法都会为局部变量和方法参数拷贝一个副本,所以栈内存是线程私有的,也就是说局部变量和方法参数是不可共享的。但是对象或者数组是在堆内存上创建的,堆内存是所有线程都可以访问的,所以包括成员变量、静态变量和数组元素是可共享的。
安全性:
原子性、内存可见性和指令重排序是构成线程安全性的三个主题
原子性操作:
public class Increment {
private int i;
public void increase(){
i++;
}
public int getI(){
return i;
}
public static void test(int threadNum,int loopTimes){
Increment increment=new Increment();
Thread[] threads=new Thread[threadNum];
for(int i=0;i<threads.length;i++){
Thread t=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<loopTimes;i++){
increment.increase();
}
}
});
threads[i]=t;
t.start();
}
for(Thread t:threads){//main线程等待其他线程都执行完成
try{
t.join();
}catch (InterruptedException e){
e.printStackTrace();
}
}
System.out.println(threadNum+"个线程,循环"+loopTimes+"次结果:"+increment.getI());
}
public static void main(String[] args){
test(20,1);
test(20,10);
test(20,100);
test(20,1000);
test(20,10000);
}
}
执行结果:
20个线程,循环1次结果:20
20个线程,循环10次结果:200
20个线程,循环100次结果:2000
20个线程,循环1000次结果:19990
20个线程,循环10000次结果:185282
预期输出:threadNum*loopTimes
执行结果与预期不一致,而且每次执行都是不一样的结果,这是什么原因?
这个就是多线程的非原子操作导致的一个不确定结果。
那么什么是原子性操作呢?就是一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能再这些操作上交替执行。Java中自带了一些原子性操作,比如给一个非long,double基本数据类型变量或者引用的赋值或者读取操作。
那i++这个操作不是一个原子性操作吗?
答:不是,这个操作其实是相当于执行i=i+1;也就是三个原子性操作:
- 读取变量i的值
- 将变量i的值加1
- 将结果写入i变量中
由于线程是基于处理器分配的时间片执行的,在这个过程中,这三个步骤可能让多个线程交叉执行。
这个图的意思就是:
- 线程1执行了increase方法先读取变量i的值,发现是5,此时切换到线程2执行increase方法读取变量i的值,发现也是5
- 线程1执行将变量i的值加1的操作,得到的结果是6,线程二也执行这个操作
- 线程1将结果赋值给变量i,线程2也将结果赋值给变量i
两个线程都执行了一次increase方法之后,最后的结果变量i从 5变到了6,而不是7
另外,由于cpu的速度非常快,这种交叉执行在执行次数较低的时候体现的并不明显,但是执行次数多的时候就十分明显了。
因此在真实编程环境中,我们往往需要某些涉及共享、可变变量的一系列操作具有原子性。
解决方案:
从共享性解决:
- 尽量使用局部变量解决问题
因为方法中的局部变量(包括方法参数和方法体中创建的变量)是线程私有的,所有无论多少线程调用没剖个不涉及共享变量的方法都是安全的。 - 使用ThreadLocal类
为了维护一些线程内可以共享的数据,java提出了一个ThreadLocal类,它提供了下边这些方法:
public class ThreadLocal<T> {
protected T initialValue() {
return null;
}
public void set(T value) {
...
}
public T get() {
...
}
public void remove() {
...
}
}
其中,类型参数T就代表了在同一个线程中共享数据的类型,它的各个方法的含义是:
- T initialValue():当某个线程初次调用get方法时,就会调用initialValue方法来获取初始值
- void set(T value):调用当前线程将指定的value参数与该线程建立一对一关系(会覆盖initialValue的值),以便后续get方法获取该值
- T get():获取与当前线程建议一对一关系的值
- void remove():将与当前线程建立一对一关系的值移除
我们可以在同一个线程里的任何代码处存取该类型的值
public class ThreadLocalDemo {
public static ThreadLocal<String> THREAD_LOCAL=new ThreadLocal<String>(){
@Override
protected String initialValue(){
return "调用initialValue方法初始化的值";
}
};
public static void main(String[] args){
ThreadLocalDemo.THREAD_LOCAL.set("与main线程关联的字符串");
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t1线程从ThreadLocal中获取的值:"+ThreadLocalDemo.THREAD_LOCAL.get());
ThreadLocalDemo.THREAD_LOCAL.set("与t1线程关联的字符串");
System.out.println("t1线程再次从ThreadLocal中获取的值:"+ThreadLocalDemo.THREAD_LOCAL.get());
}
},"t1").start();
System.out.println("main线程从ThreadLocal中获取的值:"+ThreadLocalDemo.THREAD_LOCAL.get());
}
}
执行结果是:
main线程从ThreadLocal中获取的值:与main线程关联的字符串
t1线程从ThreadLocal中获取的值:调用initialValue方法初始化的值
t1线程再次从ThreadLocal中获取的值:与t1线程关联的字符串
从这个执行结果可以看出来,不同线程操作同一个ThreadLocal对象执行各种操作而不会影响其他线程里的值比如对于一个网络程序,通常每一个请求部分都分配一个线程去处理,可以在ThreadLocal里记录一下这个请求对应的用户信息,比如用户名,登录失效时间等。虽然ThreadLocal很有用,但是它作为一种线程级别的全局变量,如果某些代码依赖它的话,会造成耦合,从而影响了 代码的可重用性。
从可变性解决
把变量声明为final。这样这个变量可以被共享,但是自从创建以后就不能被修改,可以随意访问
public class FinalDemo{
private final int finalField;
public FinalDemo(int finalField){
this.finalField=finalField;
}
}
加锁解决
锁的概念:在一个线程执行一系列操作的同时禁止其他线程执行这些操作
同步代码块:
public class Increment {
private int i;
private Object lock=new Object();
public void increase(){
synchronized (lock) {
i++;
}
}
public int getI(){
return i;
}
public static void test(int threadNum,int loopTimes){
Increment increment=new Increment();
Thread[] threads=new Thread[threadNum];
for(int i=0;i<threads.length;i++){
Thread t=new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<loopTimes;i++){
increment.increase();
}
}
});
threads[i]=t;
t.start();
}
for(Thread t:threads){//main线程等待其他线程都执行完成
try{
t.join();
}catch (InterruptedException e){
e.printStackTrace();
}
}
System.out.println(threadNum+"个线程,循环"+loopTimes+"次结果:"+increment.getI());
}
public static void main(String[] args){
test(20,1);
test(20,10);
test(20,100);
test(20,1000);
test(20,10000);
}
}
synchronized(锁对象){
需要保持原子性的一系列代码
}
一个线程获取锁之后,其他线程就不能再获取该锁,这些线程处于阻塞状态,直到已经获取锁的线程把该锁释放掉,某个线程就可以再次获得锁了。这样线程们按照获得锁的顺序执行的方式也叫做同步执行(synchronized),这个被锁保护的代码块也叫做同步代码块(这段代码被这个锁保护)。
♥♥♥♥♥在同步代码块中的代码要尽量的短,不要把不需要同步的代码也加入同步代码块,在同步代码块中千万不要执行特别耗时或者可能发生阻塞的一些操作,比如I/O操作等。♥♥♥♥♥
为什么一个对象就可以当做是一个锁呢?因为一个对象会占据一些内存,这些内存地址可以是唯一的,也就是说两个对象不能占用相同的内存
锁的重入
当一个线程请求获得已经被其他线程获得的锁的时候,它就被阻塞,但是一个线程请求一个它已经获得的锁,那么这个请求就会成功。
public class SynchronizedDemo {
private Object lock=new Object();
private void m1(){
synchronized (lock){
System.out.println("这是第一个方法");
m2();
}
}
private void m2(){
synchronized(lock){
System.out.println("这是第二个方法");
}
}
public static void main(String[] args){
SynchronizedDemo synchronizedDemo=new SynchronizedDemo();
synchronizedDemo.m1();
}
}
执行结果是:
这是第一个方法
这是第二个方法
也就是说只要一个线程持有了某个锁,那么它就可以进入任何这个锁保护的代码块
同步方法
对于成员方法来说,可以直接使用this作为锁
对于静态方法,可以直接用Class对象作为锁(Class对象可以直接在任何地方访问)
public class Increment {
private int i;
public void increase() {
synchronized (this) { //使用this作为锁
i++;
}
}
public static void anotherStaticMethod() {
synchronized (Increment.class) { //使用Class对象作为锁
// 此处填写需要同步的代码块
}
}
}
整个方法的操作都需要被同步,而且使用this作为锁的成员方法,使用Class对象作为锁的静态方法,就可以被简写成这样:
public class Increment {
private int i;
public synchronized increase() { //使用this作为锁
i++;
}
public synchronized static void anotherStaticMethod() { //使用Class对象作为锁
// 此处填写需要同步的代码块
}
}
public synchronized 返回类型 方法名(参数列表) {
需要被同步执行的代码
}
public synchronized static 返回类型 方法名(参数列表) {
需要被同步执行的代码
}
总结:
- 共享、可变的变量形成了并发编程的三大杀手:安全性、活跃性、性能
- 本文中的共享变量指的是堆内存上创建的对象或者数组,包括成员变量、静态变量和数组元素。
- 安全性问题包括三个方法,原子性操作、内存可见性和指令重排序
- 原子性操作就是一个或者某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作时不可分割的,线程不能再这些操作上交替执行。
- 为了保证某些操作的原子性,提出了下边集中解决方法:
尽量使用局部变量解决问题
使用ThreadLocal类解决问题
从共享解决,在编程时,最好使用下边这两种方案解决问题:
从可变性解决,最好让某个变量在程序过程不可变,把它使用final修饰
加锁解决 - 任何一个对象都可以作为一个锁,也称为内置锁。某个线程在进入同步代码块的时候去获取一个锁,在退出该代码块的时候把锁给释放掉。
- 锁的重入是指一个线程持有某个锁,那么它就可以进入任何被这个锁保护的代码块
- 同步方法是一种比较特殊的同步代码块,对于成员方法来讲,使用this作为锁对象,对于静态方法来说,使用Class对象作为锁对象。