Java知识梳理二

一、反射与动态代理

1.反射

       反射最大的作用之一就在于我们可以不在编译时知道某个对象的类型,而在运行时通过提供完整的”包名+类名.class”得到。注意:不是在编译时,而是在运行时。功能:
       • 在运行时能判断任意一个对象所属的类。
       • 在运行时能构造任意一个类的对象。
       • 在运行时判断任意一个类所具有的成员变量和方法。
       • 在运行时调用任意一个对象的方法。
       通俗的讲利用Java反射机制我们可以加载一个运行时才得知名称的class,获悉其构造方法,并生成其对象实体,能对其fields设值并唤起其methods。应用场景:反射技术常用在各类通用框架开发中。因为为了保证框架的通用性,需要根据配置文件加载不同的对象或类,并调用不同的方法,这个时候就会用到反射——运行时动态加载需要加载的对象。特点:由于反射会额外消耗一定的系统资源,因此如果不需要动态地创建一个对象,那么就不需要用反射。另外,反射调用方法时可以忽略权限检查,因此可能会破坏封装性而导致安全问题。

2.动态代理

       为其他对象提供一种代理以控制对这个对象的访问。在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在两者之间起到中介的作用(可类比房屋中介,房东委托中介销售房屋、签订合同等)。所谓动态代理,就是实现阶段不用关心代理谁,而是在运行阶段才指定代理哪个一个对象(不确定性)。如果是自己写代理类的方式就是静态代理(确定性)。(动态)代理模式主要涉及三个要素:其一:抽象类接口;其二:被代理类(具体实现抽象接口的类);其三:动态代理类:实际调用被代理类的方法和属性的类。
       实现方式:实现动态代理的方式很多,比如 JDK 自身提供的动态代理,就是主要利用了反射机制。还有其他的实现方式,比如利用字节码操作机制,类似 ASM、CGLIB(基于 ASM)、Javassist 等。举例,常可采用的JDK提供的动态代理接口InvocationHandler来实现动态代理类。其中invoke方法是该接口定义必须实现的,它完成对真实方法的调用。通过InvocationHandler接口,所有方法都由该Handler来进行处理,即所有被代理的方法都由InvocationHandler接管实际的处理任务。此外,我们常可以在invoke方法实现中增加自定义的逻辑实现,实现对被代理类的业务逻辑无侵入。

二、int和Integer的区别

1 int和Integer

       JDK1.5引入了自动装箱与自动拆箱功能,Java可根据上下文,实现int/Integer,double/Double,boolean/-Boolean等基本类型与相应对象之间的自动转换,为开发过程带来极大便利。最常用的是通过new方法构建Integer对象。但是,基于大部分数据操作都是集中在有限的、较小的数值范围,在JDK1.5 中新增了静态工厂方法 valueOf,其背后实现是将int值为-128 到 127 之间的Integer对象进行缓存,在调用时候直接从缓存中获取,进而提升构建对象的性能,也就是说使用该方法后,如果两个对象的int值相同且落在缓存值范围内,那么这个两个对象就是同一个对象;当值较小且频繁使用时,推荐优先使用整型池方法(时间与空间性能俱佳)。

2 注意事项

       [1] 基本类型均具有取值范围,在大数*大数的时候,有可能会出现越界的情况。
       [2] 基本类型转换时,使用声明的方式。例:long result= 1234567890 * 24 * 365;结果值一定不会是你所期望的那个值,因为1234567890 * 24已经超过了int的范围,如果修改为:long result= 1234567890L * 24 * 365;就正常了。
       [3] 慎用基本类型处理货币存储。如采用double常会带来差距,常采用BigDecimal、整型(如果要精确表示分,可将值扩大100倍转化为整型)解决该问题。
       [4] 优先使用基本类型。原则上,建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,
       [5] 如果有线程安全的计算需要,建议考虑使用类型AtomicInteger、AtomicLong 这样的线程安全类。部分比较宽的基本数据类型,比如 float、double,甚至不能保证更新操作的原子性,可能出现程序读取到只更新了一半数据位的数值。
       更过int和Integer的区别参见博客

三、Vector、ArrayList和LinkedList

Vector、ArrayList、LinkedList均为线型的数据结构,但是从实现方式与应用场景中又存在差别。

1 底层实现方式

       ArrayList内部用数组来实现;LinkedList内部采用双向链表实现;Vector内部用数组实现。

2 读写机制

       ArrayList在执行插入元素是超过当前数组预定义的最大值时,数组需要扩容,扩容过程需要调用底层System.arraycopy()方法进行大量的数组复制操作;在删除元素时并不会减少数组的容量(如果需要缩小数组容量,可以调用trimToSize()方法);在查找元素时要遍历数组,对于非null的元素采取equals的方式寻找。
       LinkedList在插入元素时,须创建一个新的Entry对象,并更新相应元素的前后元素的引用;在查找元素时,需遍历链表;在删除元素时,要遍历链表,找到要删除的元素,然后从链表上将此元素删除即可。
       Vector与ArrayList仅在插入元素时容量扩充机制不一致。对于Vector,默认创建一个大小为10的Object数组,并将capacityIncrement设置为0;当插入元素数组大小不够时,如果capacityIncrement大于0,则将Object数组的大小扩大为现有size+capacityIncrement;如果capacityIncrement<=0,则将Object数组的大小扩大为现有大小的2倍。

3 读写效率

       ArrayList对元素的增加和删除都会引起数组的内存分配空间动态发生变化。因此,对其进行插入和删除速度较慢,但检索速度很快。LinkedList由于基于链表方式存放数据,增加和删除元素的速度较快,但是检索速度较慢。

4 线程安全性

       ArrayList、LinkedList为非线程安全;Vector是基于synchronized实现的线程安全的ArrayList。需要注意的是:单线程应尽量使用ArrayList,Vector因为同步会有性能损耗;即使在多线程环境下,我们可以利用Collections这个类中为我们提供的synchronizedList(List list)方法返回一个线程安全的同步列表对象。利用PriorityBlockingQueue或Disruptor可实现基于任务优先级为调度策略的执行调度系统。

四、HashMap、HashTable和TreeMap

       三者均实现了Map接口,存储的内容是基于key-value的键值对映射,一个映射不能有重复的键,一个键最多只能映射一个值。

(1)元素特性

       HashTable中的key、value都不能为null;HashMap中的key、value可以为null,很显然只能有一个key为null的键值对,但是允许有多个值为null的键值对;TreeMap中当未实现 Comparator 接口时,key 不可以为null;当实现 Comparator 接口时,若未对null情况进行判断,则key不可以为null,反之亦然。

(2)顺序特性

       HashTable、HashMap具有无序特性。TreeMap是利用红黑树来实现的(树中的每个节点的值,都会大于或等于它的左子树种的所有节点的值,并且小于或等于它的右子树中的所有节点的值),实现了SortMap接口,能够对保存的记录根据键进行排序。所以一般需要排序的情况下是选择TreeMap来进行,默认为升序排序方式(深度优先搜索),可自定义实现Comparator接口实现排序方式。

(3)初始化与增长方式

       初始化时:HashTable在不指定容量的情况下的默认容量为11,且不要求底层数组的容量一定要为2的整数次幂;HashMap默认容量为16,且要求容量一定为2的整数次幂。扩容时:Hashtable将容量变为原来的2倍加1;HashMap扩容将容量变为原来的2倍。

(4)线程安全性

       HashTable其方法函数都是同步的(采用synchronized修饰),不会出现两个线程同时对数据进行操作的情况,因此保证了线程安全性。也正因为如此,在多线程运行环境下效率表现非常低下。因为当一个线程访问HashTable的同步方法时,其他线程也访问同步方法就会进入阻塞状态。比如当一个线程在添加数据时候,另外一个线程即使执行获取其他数据的操作也必须被阻塞,大大降低了程序的运行效率,在新版本中已被废弃,不推荐使用。HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap;可能会导致数据的不一致。如果需要同步(1)可以用 Collections的synchronizedMap方法;(2)使用ConcurrentHashMap类,相较于HashTable锁住的是对象整体, ConcurrentHashMap基于lock实现锁分段技术。首先将Map存放的数据分成一段一段的存储方式,然后给每一段数据分配一把锁,当一个线程占用锁访问其中一个段的数据时,其他段的数据也能被其他线程访问。ConcurrentHashMap不仅保证了多线程运行环境下的数据访问安全性,而且性能上有长足的提升。

(5)一段话HashMap

       HashMap基于哈希思想,实现对数据的读写。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象。当两个不同的键对象的hashcode相同时,它们会储存在同一个bucket位置的链表中,可通过键对象的equals()方法用来找到键值对。如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),链表就会被改造为树形结构。
       更多关于Map和List的区别参见博客

五、ConcurrentHashMap如何高效的实现线程安全

1.Java线程安全概述

       Java提供了不同层面的线程安全支持。在传统集合框架内部,除了Hashtable等同步容器,还提供了所谓的同步包装器(SynchronizedWrapper),我们可以调用Collections工具类提供的包装方法,来获取一个同步的包装容器(如Collections.synchronizedMap),但是它们都是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了
       • 各种并发容器,比如ConcurrentHashMap、CopyOnWriteArrayList。
       • 各种线程安全队列(Queue/Deque),如ArrayBlockingQueue、SynchronousQueue。
       • 各种有序容器的线程安全版本等。
       具体保证线程安全的方式,包括有从简单的synchronize方式,到基于更加精细化的,比如基于分离锁实现的ConcurrentHashMap等并发实现等。具体选择要看开发的场景需求,总体来说并发包内提供的容器通用场景,远优于早期的简单同步实现。

2.为什么需要ConcurrentHashMap?

       Hashtable本身比较低效,因为它的实现基本就是将put、get、size等各种方法加上“synchronized”。简单来说,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。前面已经提过HashMap不是线程安全的,并发情况会导致类似CPU占用100%等一些问题,那么能不能利用Collections提供的同步包装器来解决问题呢?看看下面的代码片段,我们发现同步包装器只是利用输入Map构造了另一个同步版本,所有操作虽然不再声明成为synchronized方法,但是还是利用了“this”作为互斥的mutex,没有真正意义上的改进!所以,Hashtable或者同步包装版本,都只是适合在非高度并发的场景下。

    private static class SynchronizedMap
        implements Map, Serializable {
        private final Map m;     // Backing Map
        final Object      mutex;        // Object on which to synchronize
        public int size() {
            synchronized (mutex) {return m.size();}
        }
    }
3.ConcurrentHashMap简析

        ConcurrentHashMap的设计实现其实一直在演化 ,比如在Java 8中就发生了非常大的变化(Java 7其实也有不少更新),所以,我这里将比较分析结构、实现机制等方面,对比不同版本的主要区别。早期ConcurrentHashMap,其实现是基于:分离锁,也就是将内部进行分段(Segment),里面则是HashEntry的数组,和HashMap类似,哈希相同的条目也是以链表形式存放。HashEntry内部使用volatile的value字段来保证可见性,也利用了不可变对象的机制以改进利用Unsafe提供的底层能力,比如volatile access,去直接完成部分操作,以最优化性能,毕竟Unsafe中的很多操作都是JVM intrinsic优化过的。你可以参考下面这个早期ConcurrentHashMap内部结构的示意图,其核心是利用分段设计,在进行并发操作的时候,只需要锁定相应段,这样就有效避免了类似Hashtable整体同步的问题,大大提高了性能。在构造的时候,Segment的数量由所谓的concurrentcyLevel决定,默认是16,也可以在相应构造函数直接指定。注意,Java需要它是2的幂数值,如果输入是类似15这种非幂值,会被自动调整到16之类2的幂数值。具体情况,我们一起看看一些Map基本操作的源码,这是JDK 7比较新的get代码。针对具体的优化部分,为方便理解,我直接注释在代码段里,get操作需要保证的是可见性,所以并没有什么同步逻辑。

    public V get(Object key) {
        Segment s; // manually integrate access methods to reduce overhead
        HashEntry[] tab;
        int h = hash(key.hashCode());
       //利用位操作替换普通数学运算
       long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
       // 以Segment为单位,进行定位
       // 利用Unsafe直接进行volatile access
       if ((s = (Segment)UNSAFE.getObjectVolatile(segments, u)) != null &&
           (tab = s.table) != null) {
               //省略
       }
       return null;
    }

       而对于put操作,首先是通过二次哈希避免哈希冲突,然后以Unsafe调用方式,直接获取相应的Segment,然后进行线程安全的put操作:

   public V put(K key, V value) {
       Segment s;
       if (value == null)
           throw new NullPointerException();
       // 二次哈希,以保证数据的分散性,避免哈希冲突
       int hash = hash(key.hashCode());
       int j = (hash >>> segmentShift) & segmentMask;
       if ((s = (Segment)UNSAFE.getObject          // nonvolatile; recheck
           (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
           s = ensureSegment(j);
       return s.put(key, hash, value, false);
   }

       其核心逻辑实现在下面的内部方法中:

    final V put(K key, int hash, V value, boolean onlyIfAbsent) {
         // scanAndLockForPut会去查找是否有key相同Node
         // 无论如何,确保获取锁
         HashEntry node = tryLock() ? null :
         scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
               HashEntry[] tab = table;
               int index = (tab.length - 1) & hash;
               HashEntry first = entryAt(tab, index);
               for (HashEntry e = first;;) {
                   if (e != null) {
                        K k;
                        // 更新已有value...
                   }else {
                        // 放置HashEntry到特定位置,如果超过阈值,进行rehash
                        // ...
                   }
                }
            } finally {
                unlock();
          }
          return oldValue;
    }

       所以,从上面的源码清晰的看出,在进行并发写操作时:ConcurrentHashMap会获取再入锁,以保证数据一致性,Segment本身就是基于ReentrantLock的扩展实现,所以,在并发修改期间,相应Segment是被锁定的。在最初阶段,进行重复性的扫描,以确定相应key值是否已经在数组里面,进而决定是更新还是放置操作,你可以在代码里看到相应的注释。重复扫描、检测冲突是ConcurrentHashMap的常见技巧。我在专栏上一讲介绍HashMap时,提到了可能发生的扩容问题,在ConcurrentHashMap中同样存在。不过有一个明显区别,就是它进行的不是整体的扩容,而是单独对Segment进行扩容,细节就不介绍了。另外一个Map的size方法同样需要关注,它的实现涉及分离锁的一个副作用。试想,如果不进行同步,简单的计算所有Segment的总值,可能会因为并发put,导致结果不准确,但是直接锁定所有Segment进行计算,就会变得非常昂贵。其实,分离锁也限制了Map的初始化等操作。所以,ConcurrentHashMap的实现是通过重试机制(RETRIES_BEFORE_LOCK,指定重试次数2),来试图获得可靠值。如果没有监控到发生变化(通过对比Segment.modCount),就直接返回,否则获取锁进行操作。在Java 8和之后的版本中,ConcurrentHashMap发生了哪些变化呢?

       总体结构上,它的内部存储变得和我在专栏上一讲介绍的HashMap结构非常相似,同样是大的桶(bucket)数组,然后内部也是一个个所谓的链表结构(bin),同步的粒度要更细致一些。其内部仍然有Segment定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构上的用处。因为不再使用Segment,初始化操作大大简化,修改为lazy-load形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。数据存储利用volatile来保证可见性。使用CAS等操作,在特定场景进行无锁并发操作。使用Unsafe、LongAdder之类底层手段,进行极端情况的优化。先看看现在的数据存储内部实现,我们可以发现Key是final的,因为在生命周期中,一个条目的Key发生变化是不可能的;与此同时val,则声明为volatile,以保证可见性。

     static class Node implements Map.Entry {
            final int hash;
            final K key;
            volatile V val;
            volatile Node next;
            // … 
        }

       我这里就不再介绍get方法和构造函数了,相对比较简单,直接看并发的put是如何实现的。

    final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node[] tab = table;;) {
            Node f; int n, i, fh; K fk; V fv;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                // 利用CAS去进行无锁线程安全操作,如果bin是空的
                if (casTabAt(tab, i, null, new Node(hash, key, value)))
                    break; 
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else if (onlyIfAbsent // 不加锁,进行检查
                     && fh == hash
                     && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                     && (fv = f.val) != null)
                return fv;
            else {
                V oldVal = null;
                synchronized (f) {
                       // 细粒度的同步修改操作... 
                    }
                }
                // Bin超过阈值,进行树化
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

       初始化操作实现在initTable里面,这是一个典型的CAS使用场景,利用volatile的sizeCtl作为互斥手段:如果发现竞争性的初始化,就spin在那里,等待条件恢复;否则利用CAS设置排他标志。如果成功则进行初始化;否则重试。请参考下面代码:

    private final Node[] initTable() {
        Node[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            // 如果发现冲突,进行spin等待
            if ((sc = sizeCtl) < 0)
                Thread.yield(); 
            // CAS成功返回true,则进入真正的初始化逻辑
            else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node[] nt = (Node[])new Node[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

       当bin为空时,同样是没有必要锁定,也是以CAS操作去放置。在同步逻辑上,它使用的是synchronized,而不是通常建议的ReentrantLock之类,这是为什么呢?现代JDK中,synchronized已经被不断优化,可以不再过分担心性能差异,另外,相比于ReentrantLock,它可以减少内存消耗,这是个非常大的优势。与此同时,更多细节实现通过使用Unsafe进行了优化,例如tabAt就是直接利用getObjectAcquire,避免间接调用的开销。

    static final  Node tabAt(Node[] tab, int i) {
        return (Node)U.getObjectAcquire(tab, ((long)i << ASHIFT) + ABASE);
    }

       再看看,现在是如何实现size操作的。阅读代码你会发现,真正的逻辑是在sumCount方法中,那么sumCount做了什么呢?

    final long sumCount() {
        CounterCell[] as = counterCells; CounterCell a;
        long sum = baseCount;
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += a.value;
            }
        }
        return sum;
    }

       我们发现,虽然思路仍然和以前类似,都是分而治之的进行计数,然后求和处理,但实现却基于一个奇怪的CounterCell。难道它的数值,就更加准确吗?数据一致性是怎么保证的?

    static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }

       其实,对于CounterCell的操作,是基于java.util.concurrent.atomic.LongAdder进行的,是一种JVM利用空间换取更高效率的方法,利用了Striped64内部的复杂逻辑。这个东西非常小众,大多数情况下,建议还是使用AtomicLong,足以满足绝大部分应用的性能需求。

4.ConcurrentHashMap在JDK1.7和JDK1.8中的区别

JDK1.7
       put加锁。通过分段加锁segment,一个hashmap里有若干个segment,每个segment里有若干个桶,桶里存放K-V形式的链表,put数据时通过key哈希得到该元素要添加到的segment,然后对segment进行加锁,然后在哈希,计算得到给元素要添加到的桶,然后遍历桶中的链表,替换或新增节点到桶中。size分段计算两次,两次结果相同则返回,否则对所以段加锁重新计算
JDK1.8
       put CAS 加锁。jdk1.8中不依赖与segment加锁,segment数量与桶数量一致;首先判断容器是否为空,为空则进行初始化利用volatile的sizeCtl作为互斥手段,如果发现竞争性的初始化,就暂停在那里,等待条件恢复,否则利用CAS设置排他标志(U.compareAndSwapInt(this, SIZECTL, sc, -1));否则重试对key hash计算得到该key存放的桶位置,判断该桶是否为空,为空则利用CAS设置新节点否则使用synchronize加锁,遍历桶中数据,替换或新增加点到桶中最后判断是否需要转为红黑树,转换之前判断是否需要扩容。size
利用LongAdd累加计算。

你可能感兴趣的:(Java知识梳理二)