详解线程安全

1.为什么会有线程

多进程是为了解决并发编程,更加充分利用多核CPU资源。但是进程的创建和销毁开销大,就引入了线程。一个进程包含至少一个线程,创建第一个线程的时候会分配好资源,后续在创建线程就直接共享前面的资源,节省了分配资源的开销。

2.线程不安全的可能原因

在多线程环境下代码执行出现bug的情况被称为“线程不安全”。

可能的原因如下

  1. 抢占式执行:这是线程不安全的万恶之源,多个线程在调度的时候是全随机的。这是内核实现的,咱们无能为例
  2. 多个线程修改同一个变量: 有的时候能通过调整代码,来规避这个问题,但是普适性不高。

String是不可变对象,因为把set系列方法不对外公布,这就让String变成线程安全的,因为多个线程无法修改同一个变量

  1. 修改操作不是原子的:例如++操作本质上是3个cpu指令,load,add,save。CPU上是以一个指令为最小单位,不会一个指令执行到一半就让其他线程调度走。因此要想让线程安全就把这多个操作给打包成原子操作即加锁,这也是解决线程安全问题最常用的方法。

详解线程安全_第1张图片

  1. 内存可见性问题: 这是编译器优化导致的bug
  2. 指令重排序:这也是编译器优化导致的bug

3.如何解决线程不安全问题

咱们上面说了,解决线程不安全最常用的方法就是加锁,让操作变成原子的。加锁就类似于多个男孩子追同一个女孩子,如果男生A追到了,就会在朋友圈官宣类似于加锁,此时其他男孩子就要阻塞等待,直到这个男生分手(释放锁)。其他男生要阻塞等待是因为他们追的是同一个女生(锁发生了竞争)。当然如果是两个男生分别追两个女孩子,男生A和男生B不存在竞争,就不用阻塞等待(不存在锁竞争)。

3.1加锁的关键字synchronized

以加锁count++为例

public class Test5 {
	private static int count = 0;
	public synchronized static void increase() {
		count++;
	}
	public static void main(String[] args) throws InterruptedException {
		Thread t1 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase();
			}
		});
		Thread t2 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase();
			}
		});
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(count);
	}
}

上面的加锁操作会让并发变成串行,降低了执行效率。但是很多人有误区,以为变串行还有必要用多线程吗?上面的加锁操作只是让increse()变串行了,但是两个for循环还是并发执行,效率还是比单线程高的。

加锁过程:

详解线程安全_第2张图片
加锁并不代表CPU一次性完全执行忘,中间也是会发生调度切换。但是即使t1切换走了,t2仍然是BLOCKED状态,无法在CPU上运行。这就类似于图书馆占座,人去上个厕所(调度走了)其他人想坐这个座位(有锁竞争,要阻塞等待)也没办法坐

误区:加锁了就一定线程安全
public class Test5 {
	private static int count = 0;
	public synchronized static void increase() {
		count++;
	}
	public static void increase2() {
		count++;
	}
	public static void main(String[] args) throws InterruptedException {
		Thread t1 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase();
			}
		});
		Thread t2 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase2();
			}
		});
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(count);
	}
}

t1线程调用increase()确实是加锁了,但是t2线程没调用increase2()没有加锁。只有一个线程加锁,没有锁竞争,也就不会有阻塞等待,也就不会让并发修改变成串行修改。就类似A追到女生(加锁了),但是B不讲武德,在守门员面前进球(不加锁,没阻塞等待)。

Java中锁对象是任意的
  1. 可以修饰方法,上面的代码就是锁方法
  2. 可以修饰代码块
  3. 修饰静态变量/方法
public class Test6 {
	private static int count = 0;
	static class Increase {
		public void add() {
			//代码块加锁
			synchronized (this) {//谁调用add方法,谁就是this
				count++;
			}
		}
	}
	public static void main(String[] args) throws InterruptedException {
		Increase increase = new Increase();
		Thread t1 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase.add();//this就是increase对象,针对increase对象加锁
			}
		});
		Thread t2 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase.add();//针对increase对象加锁产生锁竞争
				//如果这里是 increase2.add()那这里的this就是increase2对象,此时针对increase2加锁,两个线程针对不同对象加锁,不会产生锁竞争,锁竞争是为了其中一个线程阻塞等待,才能保证线程安全。
			}
		});
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(count);
	}
}

public class Test6 {
	private static int count = 0;
	static class Increase {
		public Object locker = new Object();
		public void add() {
			//针对locker对象加锁,locker是Increase的一个普通变量,每个Increase实例都有自己的locker实例
			synchronized (locker) {
				count++;
			}
		}
	}
	public static void main(String[] args) throws InterruptedException {
		Increase increase = new Increase();
		Thread t1 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase.add();
			}
		});
		Thread t2 = new Thread(() -> {
			for (int i = 0; i < 50000; i++) {
				increase.add();//此时的increase对象是同一个,对应的increase中的locker就是同一个对象,此时两个线程针对同一个对象加锁,仍然会存在锁竞争,就会阻塞等待。
				//如果这里是increase2对象,那么increase对象中有一个locker,increase2对象中有一个locker,两个locker不同,即两个线程针对两个对象加锁,不会发生锁竞争,线程不安全
			}
		});
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(count);
	}
}

public class Test7 {
    public static int count = 0;
    //静态内部类
    static class Increase {
        //静态成员
        private static Object locker = new Object();
        public void add() {
            //对类对象加锁
            synchronized (locker) {
                count++;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Increase increase1 = new Increase();
        Increase increase2 = new Increase();
        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase1.add();
                }
            }
        };
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase2.add();
					//increase1和increase2虽然是两个不同的对象,但是这两个对象中的locker是类属性(唯一的),即对同一个locker加锁,产生锁竞争,会阻塞等待,线程安全
                }
            }
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}
public class Test8 {
    public static int count = 0;
    static class Increase{
        public void add() {
            //针对类对象加锁
            synchronized (Increase.class) {//反射
                count++;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Increase increase1 = new Increase();
        Increase increase2 = new Increase();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase1.add();
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase2.add();//虽然是两个不同的对象,但是对同一个类对象Increase.class加锁,存在锁竞争,会阻塞等待,线程安全
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

总结:任意对象都可以在synchronized里面作为锁对象,在多线程代码中,我们不关心锁对象是谁,只关心,两个线程是否锁同一个对象,锁同一个对象才有锁竞争,才会阻塞等待;锁不同对象就没有锁竞争

3.2使用volatile解决内存可见性问题

class Counter{
    public static int count = 0;
}
public class Test1 {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (Counter.count == 0) {

            }
            System.out.println("执行不到");
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            System.out.println("请输入一个数");
            Scanner scanner = new Scanner(System.in);
            Counter.count = scanner.nextInt();
        });
        t2.start();
        t1.join();
        t2.join();
    }
}

详解线程安全_第3张图片
我们发现t2线程修改了count值,却没有输出“执行不到”,这主要怪编译器优化。
线程t1的代码中while (Counter.count == 0) 会频繁的进行load和cmp操作,其中load操作要不断的从内存中读取数据,而cmp只是在寄存器上不断进行比较,load消耗的时间长,比cmp慢了3-4个数量级。编译器就会帮你优化,既然你频繁的load,而且load结果还一样,那就只执行一次Load操作,后续进行cmp不用再重新读取内存了。在单线程环境下,这个优化是正确的,但是在多线程环境下一个线程把内存改了,但是另外一个线程感知不到,这就是内存可见性问题
解决方案:告诉编译器这个地方不要优化,即使用volatile修饰变量
详解线程安全_第4张图片

JMM

他的全称是Java Memory Model,我们刚才说过volatile禁止了编译器优化,避免直接读取CPU寄存器中缓存的数据,而是每次都重新读取内存。Java设计时就是让程序员不关注硬件设备,为此Java自己搞了个JMM模型,即把CPU寄存器+缓存统称为工作内存,把物理内存称为主内存
在JMM角度看volatile:正常程序执行的过程中,会把主内存的数据,先加载到工作内存中,再进行计算处理。编译器优化可能导致不是每次都读取主内存,而是直接读取工作内存的缓存数据,这就可能导致内存可见性问题。volatile起到的效果就是保证每次读取内存时都是真的从主内存中重新读取。

3.3使用volatile防止内存可见性问题

public Instance getInstance() {
	if(instance == null) {
		sychronized(this) {
			if(instance == null) {
				instance = new Instance();
			}
		}
	}
	return instance;
}

以new对象为例,new对象的可大致分为3个步骤:1. 申请内存,得到内存首地址;2. 调用构造方法,来初始化实例; 3. 把内存首地址赋值给instance引用。这个场景可能会导致”指令重排序“,在单线程角度下,2和3是可以调换的,执行效果一样。但是在多线程环境下如果按照1,3,2顺序执行,有可能t1线程执行了1和3之后,还没执行2之前,t2线程调用getInstance(),此时就会认为instance不为null,直接返回Instance,如果后续对Instance进行解引用就可能导致空指针异常。
为了避免指令重排序,我们可以使用volatile修饰变量,即使用volatile修饰instance

你可能感兴趣的:(javaee,操作系统,安全,java,jvm)