java多线程与并发(四)——java内存模型

《Java并发编程的艺术》第三章学习笔记

一、内存模型的基础

并发编程中的两个关键问题:
1、线程之间如何通信(线程之间交换信息的机制有两种:共享内存和消息传递
在共享内存的并发模型中,线程之间共享程序的公共状态,通过写-读进行隐式通信;在消息传递中,必须通过发送消息显式通信。
2、线程之间如何同步(同步:指在程序中控制不同线程间操作发生相对顺序的机制)
共享内存中,同步显式进行;消息传递中,隐式进行。

在Java中的并发采用的是共享内存模型,整个通信对程序员完全可见。存在内存可见性问题
1.1实例域、静态域和数组元素存储在堆内存中,在线程间共享
1.2局部变量、方法定义参数和异常处理器参数不会在线程间共享,不存在内存可见性问题,也不受内存模型影响
1.3每一个线程有自己抽象的私有的本地内存(缓存、写缓冲区、寄存器以及其他硬件和编译器优化),其中有该线程的读/写共享变量的副本。
1.4编译器和处理器会对指令做重排序,重排序分为3种:
1)编译器优化的重排序。(不改变单线程语义的前提下,可以重排序指令的执行顺序)
2)指令级并行的重排序。(不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序)
3)内存系统的重排序。(因为处理器使用缓存和读/写缓冲区)

以上重排序可能会导致内存可见性问题
java内存模型(JMM)是语言级的内存模型。

java插入内存屏障禁止处理器重排序。

1.5常见的处理器有:sparc-TSO和X86(较强的内存模型,只允许对写/读操作做重排序)、IA64、PowerPC
1.6 happens-before:在JMM中,如果一个操作执行的结果对另一个操作可见,这两个操作必须存在happens-before关系。(可以是一个线程内,也可以是不同线程间)
JMM中一个happens-before规则对应一个或多个编译器和处理器的重排序规则。

java内存模型详细介绍

1、Java运行时数据区域

运行时数据区包括:虚拟机栈区,堆区,方法区,本地方法栈,程序计数器.
虚拟机栈区 :也就是我们常说的栈区,线程私有,存放基本类型,对象的引用和 returnAddress ,在编译期间完成分配。
堆区 : JAVA 堆,也称 GC 堆,所有线程共享,存放对象的实例和数组, JAVA 堆是垃圾收集器管理的主要区域。
方法区 :所有线程共享,存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。这个区域的内存回收目标主要是针对常量池的对象的回收和对类型的卸载。
程序计数器 :线程私有,每个线程都有自己独立的程序计数器,用来指示下一条指令的地址。

2、内存泄漏和内存溢出

2.1内存泄漏主要有两种情况:
  1. 在堆中申请的空间没有被释放
  2. 对象已不再使用,但还仍然在内存中保留着
    典型例子:一个没有重写hashCode()和equals()方法的key类在HAshMap中保存的情况,最后会有很多重复的对象。所有的内存泄漏最后都会抛出OutOfMemoryError(java.lang.OutOfMemoryError:Java heap space)异常
    泄漏的原因:
    1)静态集合类
    2)各种连接,如数据库连接
    3)监听器
    4)变量不合理的作用域
    解决的方法:
    1)避免在循环中创建对象
    2)尽早释放无用对象的引用
    3)少用静态变量
    4)使用字符串处理,避免使用String,应大量使用StringBuffer
    如何查找内存泄漏:使用Jconsole(如果内存的大小持续地增长,则说明系统存在内存泄漏)
2.2内存溢出:指程序运行过程中无法申请到足够的内存而导致的错误

除了程序计数器外,其他几个运行时区域都有可能发生内存溢出(OutOfMemoryError)的可能:
1)虚拟机栈和本地方法栈溢出:
线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
2)堆溢出:java.lang.OutOfMemoryError:Java heap space
如何解决:首先通过内存映像分析工具对dump出来的堆转存快照进行分析,重点是确认内存中的对象是否是必要的,先分清是因为内存泄漏还是内存溢出。
如果是内存泄漏,进一步通过工具查看泄漏对象到GC Roots的引用链。找到无法自动回收的原因。
不存在泄漏,检查虚拟机的参数(-Xmx与-Xms)的设置是否适当
3)方法区溢出:java.lang.OutOfMemoryError:PermGen space
4)运行时常量池溢出:java.lang.OutOfMemoryError:PermGen space

如何向运行时常量池中添加内容,最简单的做法是使用String.intern()这个Native方法——作用是:如果池中包含一个等于此String的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
常量池分配在方法区内,通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,间接限制常量池的大小。

导致内存溢出的原因:
1)内存中加载的数据过大,如一次从数据库取出过多数据
2)集合类中有对对象的引用,使用完后未清空,JVM不能回收
3)存在死循环或循环产生过多重复的对象实体
4)启动参数内存值设定的过小

内存溢出的解决办法:
step1:修改JVM启动参数,直接增加内存(-Xms,与-Xmx设置相同,避免每次GC后调整堆的大小;建议对堆的最大值设置为内存最大值的80%
step2:检查错误日志,查看“OutOfMemory”错误前的异常
step3:对代码进行走查和分析,找出可能发生内存溢出的位置
step4:使用内存查看工具Jconsole,动态查看内存使用情况

永久代会不会导致内存溢出?
在jvm中的永久代中主要存放是经过几次GC之后依旧没有被回收的对象,而永久代不是经常进行GC,所以在项目运行汇总,如果加载了大量的类,永久代没有及时的回收,后面再向永久代中分配内存的时候,发现已经没有内存可以分配,就会出现内存溢出(java.lang.OutOfMemoryError:PermGen space )此时会触发完全垃圾回收(FullGC)
在GC中如何使对象生存一次:对象使用finalize方法,一次对象的自我拯救。

3、如何减少gc出现的次数(重点!!!)

  1. 对象不用时最好显示设置为null
  2. 尽量少用System.gc()
  3. 尽量少用静态变量
  4. 尽量使用StringBuffer,而不用String来累加字符串
  5. 分散对象创建或删除的时间
  6. 尽量少使用finalize函数
  7. 经常使用的图片,使用软引用类型,尽可能将图片保存在内存中,供程序调用
  8. 能用基本类型int、long,就不用Integer、Long对象。
  9. 增大-Xmx的值

4、JVM中常见的启动参数:

  1. -Xms:设置堆的最小值
  2. -Xmx:设置堆的最大值
  3. -Xmn:设置新生代的大小
  4. -Xss:设置每个线程的栈大小
  5. -XX:NewSize:设置新生代的初始值
  6. -XX:MaxNewSize:设置新生代的最大值
  7. -XX:PermSize:设置永久代的初始值
  8. -XX:MaxPermSize:设置永久代的最大值
  9. -XX:SurviorRatio:年轻代Eden区域Survior区的大小比值
  10. -XX:PretenureSizeThreshold:令大于这个设置值的对象直接在老年代分配

5、几种常用的内存调试工具:

  1. jps:查看虚拟机进程的状况,如进程ID
  2. jmap:用于生成堆转储快照文件(某一时刻的)
  3. jhat:对生成堆转储快照文件进行分析
  4. jstack:用来生成线程快照(某一时刻的)。目的是:定位线程长时停顿的原因(死锁、死循环、等待I/O等),通过查看各个线程多的调用堆栈,可以知道没有响应的线程在后台做了什么或者等待什么资源
  5. jstat:虚拟机统计信息监视工具。(显示垃圾收集情况、内存使用情况)
  6. Jconsole:内存监控和线程监控。可以显示内存的使用情况;遇到线程停顿时,可以使用这个功能
    具体参数参考:https://blog.csdn.net/eos2009/article/details/78522901

6、动态加载类的框架?

二、内存模型中的顺序一致性

2.1重排序遵守的规则:
1)数据依赖性:编译器和处理器不会对存在数据依赖性(只考虑单个处理器中和单个线程中的操作)的操作重排序
2)as-if-serial语义:不管怎么重排序,执行结果不变
3)程序顺序规则:happens-before
重排序会破坏多线程程序的语义。
2.2顺序一致性内存模型:理想化的内存模型
顺序一致性内存模型有两大特性:
1)一个线程的所有操作必须按照程序的顺序执行
2)每个操作必须原子执行且立刻对所有线程可见(不论是否同步)(JMM中没有这个保证)
JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

三、同步原语(synchronized、volatile和final)

1、volatile的内存语义

volatile变量具有下列特性:
1)可见性:对一个volatile变量的读,总是能看到(任意线程)对于volatile变量最后的写入。
2)原子性:对任意单个volatile变量的读/写具有原子性,但volatile++这种复合操作不具有原子性。
volatile变量从JDK5开始可以实现线程之间的通信。
volatile变量的写与锁的释放有相同的内存语义;
volatile变量的读与锁的获取有相同的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。消除将从主内存中读取共享变量。
线程A写volatile变量,线程B读volatile变量,实际上就是线程A通过主内存向线程B发送消息。
volatile内存语义的实现:JMM采取保守策略,基于保守策略的JMM内存屏障插入策略。即在volatile变量写之前插入StoreStore屏障,禁止上面的普通写与volatile写重排序;
在volatile变量写之后插入StoreLoad屏障,避免与后面的volatile变量读/写操作重排序;
在volatile变量读后面插入LoadLoad屏障,禁止下面的volatile变量读与上面的volatile变量读重排序;
在volatile变量读之后插入LoadStore屏障,禁止下面的普通写操作与他重排序。
与锁的区别:
1)volatile变量只对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。
2)在功能上,锁比volatile更强大;
3)在可伸缩性和执行性能上,volatile更有优势。
用volatile替换锁序谨慎!!!

2、锁(synchronized)的内存语义:

synchronized是Java并发编程中最重要的同步机制。
作用:让临界区互斥执行;让释放锁的线程向获取同一个锁的线程发送消息。
锁的释放/获取原理同volatile的写/读。
锁内存语义的实现:
以ReentrantLock为例:

public class ReentrantLockExample {
    int a=0;
    ReentrantLock lock=new ReentrantLock();
    public void writer(){
        lock.lock();
        try{
            a++;
        }finally {
            lock.unlock();
        }
    }
    public void reader(){
        lock.lock();
        try {
            int i=a;
        }finally {
            lock.unlock();
        }
    }
}

调用lock()方法获取锁,调用unlock()方法释放锁。
ReentrantLock分为公平锁和非公平锁,其实现的内存语义:
1)公平锁和非公平锁释放时,最后都要写一个volatile变量state;
2)公平锁获取时,首先回去读volatile变量
3)非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。

/**
*使用公平锁的加锁,lock()的轨迹如下:
*1)ReentrantLock:lock()
*2)FairSync:lock()
*3)AbstractQueuedSynchronized:acquire(int arg)
*4)ReentrantLock:tryAcquire(int acquires) 
*/
 static final class FairSync extends Sync {
        private static final long serialVersionUID = -3000897897090466540L;

        final void lock() {
            acquire(1);
        }
        .....
}
        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();//获取锁的开始,首先读volatile变量state
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
  /**
  *公平锁解锁方法unlock()轨迹如下:
  *1)ReentrantLock:unlock()
  *2)AbstractQueuedSynchronized:release(int arg)
  *3)Sync:tryRelease(int release).
  */
  public void unlock() {
        sync.release(1);
    }
 protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);//释放锁的最后,写volatile变量state
            return free;
        }

而非公平锁的释放与公平锁完全相同,非公平锁的获取如下:

/**
*非公平锁的获取如下:
*1)ReentrantLock:lock()
*2)NonfairSync:lock()
*3)AbstractQueuedSynchronized:compareAndSetState(int except,int update).
*/
protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
/**以上方法以原子操作的方式更新state变量*/

java中把compareAndSet()方法调用简称为CAS,同时具有volatile的读和写的内存语义。
因此,锁的释放-获取的内存语义的实现至少有以下两种方式:
1)利用volatile变量的写-读所具有的内存语义;
2)利用CAS所附带的volatile读和volatile写的内存语义。

3、final语义:

1)在构造函数内对一个final域的写入,与随后把这个被构造函数对象的引用赋值给一个引用变量,这两个操作不能重排序
2)初次读一个final域的对象的引用,与随后初次读这个final域,这两个不能重排序

四、双重检查锁定与延迟初始化

在多线程中,延迟初始化来降低初始化类和创建对象的开销。
双重检查锁定是常见的延迟初始化技术,但是错误的用法。(P67页说明)

未完,待更。。。。

你可能感兴趣的:(java多线程和并发,多线程)