Описание коллекции на GitHub: https://github.com/Nyquest/anar
Описание коллекции на GitHub: https://github.com/Nyquest/anar
В предыдущей статье был рассмотрен расчет оптимальных параметров фильтра Блума и его реализация.
Со статьей можно ознакомиться здесь: Фильтр Блума
Для оценки эффективности будем рассматривать сколько оперативной памяти потребляют различные структуры данных в Java 11.
Для примера будем размещать в памяти информацию о 20 миллионах ИИН и делать наблюдения через VisualVM.
ИИН представляет собой строку из 12 символов.
Хранение 20 миллионов ИИН в ArrayList:
Создается 20 миллионов объектов String, каждый объект String ссылается на массив байт из 12 элементов.
Элемент | Размер | Кол-во | Общий размер |
---|---|---|---|
String | 20 B | 20 000 000 | 553 MB |
byte[12] | 36 B | 20 000 000 | 687 MB |
Суммарно данный способ занимает в памяти примерно 1240 MБ.
Хранение 20 миллионов ИИН в HashSet<String>:
Создается 20 миллионов объектов таких же как и для ArrayList, дополнительно хранятся узлы корзины HashMap.Node (под капотом HashSet находится HashMap).
Элемент | Размер | Кол-во | Общий размер |
---|---|---|---|
String | 20 B | 20 000 000 | 553 MB |
byte[12] | 36 B | 20 000 000 | 687 MB |
HashMap.Node | 44 B | 20 000 000 | 839 MB |
Суммарно данный способ занимает в памяти примерно 2079 MБ.
Хранение 20 миллионов ИИН в HashSet<Long>:
Создается 20 миллионов объектов Long и HashMap.Node.
Элемент | Размер | Кол-во | Общий размер |
---|---|---|---|
Long | 24 B | 20 000 000 | 458 MB |
HashMap.Node | 44 B | 20 000 000 | 839 MB |
Суммарно данный способ занимает в памяти примерно 1297 MБ.
Создается 20 миллионов объектов Long.
Элемент | Размер | Кол-во | Общий размер |
---|---|---|---|
Long | 24 B | 20 000 000 | 458 MB |
Суммарно данный способ занимает в памяти примерно 458 MБ.
Создается массив из 20 миллионов элементов типа long.
Элемент | Размер | Кол-во | Общий размер |
---|---|---|---|
long | 8 B | 20 000 000 | 152 MB |
Суммарно данный способ занимает в памяти примерно 152 MБ.
В реализации MyBloomFilter используется BitSet, BitSet в свою очередь хранит биты в виде массива long, поэтому основную память забирает этот массив.
В нашей реализации BitSet хранит информацию о 268 435 456 битах.
В один long помещается 64 бит.
268 435 456 / 64 = 4 194 304 (кол-во элементов в массиве)
Практически нет дополнительных потерь при хранении байтов (24 байта транится на сам массив).
268 435 456 бит / 8 = 33 554 432 байт
Суммарно данный способ занимает в памяти примерно 32 MБ.
Как было рассмотрено в предыдущей статье о фильтре Блума, необходимо рассчитать оптимальное кол-во бит для хранения.
Для 20 миллионов ИИН с вероятностью ошибок 1% оптимальное кол-во бит составляет 191 701 168.
BloomFilter из набора guava для хранения бит использует AtomicLongArray, который в свою очередь использует для хранения бит также long массив (long[]).
Рассчитаем кол-во элементов в массиве:
⌈191 701 168 / 64⌉ = ⌈2 995 330.75⌉ = 2 995 331
Теперь рассчитаем размер в байтах:
191 701 168 бит / 8 = 23 962 646 байт
Суммарно данный способ занимает в памяти примерно 23 MБ.
Структура данных | Потребляемая память | Примечание |
---|---|---|
HashSet<String> | 2079 MБ | Оптимальные операции добавления и поиска, но высокое потребление памяти |
HashSet<Long> | 1297 MБ | Дополнительная работа с Long, из-за наличия у ИИН ведущих нулей |
ArrayList<String> | 1240 MБ | Список должен быть отсортирован, что была возможность поиска |
ArrayList<Long> | 458 MБ | Список должен быть отсортирован. Дополнительная работа с Long, из-за наличия у ИИН ведущих нулей. |
long[] | 152 MБ | Фиксированный размер. |
MyBloomFilter | 32 MБ | Неоптимальная реализация |
Bloom Filter (Google Guava) | 23 MБ | Оптимальная реализация |
В данной статье было рассмотрено несколько вариантов хранения информации о 20 миллионах ИИН, где вместо 1-2 гигабайт, можно обойтись 20-30 мегабайтами (экономия на порядок) при допущении заданной вероятности ошибок.
Но кол-во данных может быть гораздо больше, как и сами данные могут быть больше, и применение фильтр Блума в данных случаях будет еще заметнее.
Фильтр Блума имеет достаточно специфические варианты использования, и есть небольшая вероятность, что он вам пригодится.
Давно хотелось попробовать реализовать фильтр Блума (хотя я его называют фильтром Блюма).
Фильтр Блума - это вероятностная структура данных, позволяющая проверить принадлежность элемента к множеству. Данная структура может точно ответить на запрос об отсутствии элемента в множестве, но может ошибаться с заданной нами вероятностью о наличии элемента во множестве.
В некоторых задачах фильтр Блума очень полезен, позволяет хранить меньше данных в оперативной памяти, позволяет снизить кол-во обращений к диску или другому оберегаемому ресурсу.
Фильтр Блума может использоваться, например, для подсчета уникальных посетителей, для проверки орфографии, в различных СУБД для уменьшения обращения к ПЗУ (постоянное запоминающее устройство).
Будем пробовать фильтр Блума на примере хранения 20 миллионов ИИНов.
ИИН - индивидуальный идентификационный номер физического лица в Республике Казахстан, состоящий из 12 цифр.
Я заранее сгенерировал список из 20 миллионов ИИНов (102 MB).
Скачать список можно здесь: https://drive.google.com/file/d/1SIfp_CRl4cQetc3Kw2jqDcWpF3fYPc2s/view?usp=sharing
Исходные коды генератора можно посмотреть в github: https://github.com/Nyquest/iin_generator
Фильтр Блума должен преобразовать наше множество из 20 миллионов ИИН в набор битов с помощью нескольких хэш-функций.
ИИН считается принадлежащим множеству, если значения всех вычисленных хэш-функции от ИИНа указывают на номера отмеченных бит. Если хотя бы один бит не отмечен, ИИН однозначно не принадлежит множеству.
Оптимальное количество бит и достаточное кол-во хэш-функций можно рассчитать по формулам.
Формула оптимального количества бит:
m - искомый размер битового массива (кол-во бит);
n - размер исходного множества;
ln - функция натурального логарифма;
ε - желаемая вероятность ошибок (например, 0.01 - ожидаем 1% ошибок)
Формула достаточного кол-ва кэш-функций зависит от выделенного кол-ва бит:
k - кол-во кэш-функций;
m - кол-во битов в результирующем массиве;
n - кол-во элементов в исходном множестве;
ln - функция натурального логарифма.
Результатом выполнения каждой кэш-функции является номер бита, который нужно выставить (отметить). Таким образом, несколько хэш-функции высталяют различные биты в массиве бит. Хотя номера могут случайно совпадать для различных хэш-функций. Независимость хэш-функции друг от друга позволяет уменьшить повторяемость их результатов.
Самой главной проблемой при реализации фильтра Блума является выбор хэш-функций.
Первое, что пришло в голову это брать SHA-256 (привет любителям блокчейна) от ИИНа, и части SHA-256 использовать как отдельные значения хэш-функций.
Но, на самом деле, SHA-256 недостаточно, поэтому был взят SHA-384.
Чтобы было удобнее брать части SHA-384 делается выравнивание размера битового массива.
Как и требуется, хэши SHA обладают лавинным эффектом, небольшое изменений в хэшируемых данных меняет хэш до неузнаваемости.
Функция расчета оптимального кол-во бит
/**
* Вычислить оптимальное кол-во бит
* @param totalCount общее кол-во элементов
* @param errorProbability вероятность ошибок
* @return оптимальное кол-во бит
*/
static long optimalBitCount(long totalCount, double errorProbability) {
return (long) Math.ceil(
-totalCount * Math.log(errorProbability) / Math.pow(Math.log(2), 2));
}
Подставим наши значения (20 миллионов - кол-во элементов, 0.01 - ожидаем 1% ошибок) и получим размер битового массива, равный 191 701 168:
System.out.println(optimalBitCount(20_000_000, 0.01)); // 191_701_168
Но, именно для нашей реализации, данный размер не подходит, так как не все биты используются.
Это можно увидеть, переведя размер 191 701 168 в двоичный вид 1011011011010010000010110000:
System.out.println(Long.toBinaryString(optimalBitCount(20_000_000, 0.01)));
Чтобы задействовать возможные биты, нужно сделать расчет размера с выравниванием:
/**
* Вычислить оптимальное кол-во бит с выравниванием
* @param totalCount общее кол-во элементов
* @param errorProbability вероятность ошибок
* @return оптимальное кол-во бит в порции с выравниванием
*/
static long optimalBitCountWithAlignment(long totalCount, double errorProbability) {
long result = optimalBitCount(totalCount, errorProbability);
long l = Long.highestOneBit(result);
return l == totalCount ? totalCount : l << 1;
}
Еще раз подставим наши значения, получим размер, равный 268 435 456
System.out.println(optimalBitCountWithAlignment(20_000_000, 0.01)); // 268_435_456
Переведем 268 435 456 - 1 в двойчный вид 1111111111111111111111111111 (28 бит).
От размера массива нужно вычесть единицу, так как индексация массива идет с 0 и нужно посмотреть битовое представление последнего индекса.
System.out.println(Long.toBinaryString(
optimalBitCountWithAlignment(20_000_000, 0.01) - 1));
Размер стал больше, но зато все биты задействованы.
Таким образом, считаем размер битового массива равным 268 435 456.
Функция расчета кол-ва хэш-функций:
/**
* Расчет кол-ва хэш-функций
* @param totalCount общее кол-во элементов
* @param bitArraySize размер битового массив
* @return минимальное кол-во хэш-функций
*/
static long hashFunctionCount(long totalCount, long bitArraySize) {
return (long) Math.ceil(Math.log(2) * ((double)bitArraySize / totalCount));
}
Подставим наши значения, получим 10 хэш-функций:
System.out.println(hashFunctionCount(20_000_000, 268_435_456)); // 10
268 435 456 значений можно закодировать 28 битами, что составляет 3.5 байта.
Для удобства мы можем брать по 4 байта хэш-функции SHA, при этом 3 байта целиком, а один только на половину, другую же половину просто игнорируем.
Итого там потребуется 40 байт, которые есть в SHA-384.
Хэш-функция | Кол-во бит | Кол-во байт |
---|---|---|
SHA-256 | 256 | 32 |
SHA-384 | 384 | 48 |
Из SHA-384 можно получить не 10, а 12 функций. Чем больше функций, тем меньше будет ошибок.
Все необходимые расчеты сделаны, можно приступать к реализации.
Для начала реализуем получение хэша от строки (ИИНа) в виде массива байт.
MessageDigest digest = MessageDigest.getInstance("SHA-384");
byte[] digest(String value) {
return this.digest.digest(value.getBytes(StandardCharsets.UTF_8));
}
Биты будем хранить в стандартной структуре данных BitSet. У данный структуры есть методы set и get для установки и чтения значения соответствующего бита.
BitSet bitSet = new BitSet(1 << 28); // размер BitSet, равный 268_435_456
Реализуем функцию перевода четырех байт в число, являющееся номером позиции бита в BitSet.
/**
* переводим четыре байта в число
* @param digest массив байт
* @param position позиция, от которой берутся 4 байта
* @return число
*/
private int bitsToInt(byte[] digest, int position) {
return ((((1 << 4) - 1)) & digest[position]) << 24 // берем половину байта и смещаем его на 24 позиции влево
| (0xff & digest[position + 1]) << 16 // берем следующий целый байт и смещаем его на 16 позиций влево
| (0xff & digest[position + 2]) << 8 // берем следующий целый байт и смещаем его на 8 позиций влево
| (0xff & digest[position + 3]); // берем следующий целый байт без каких-либо смещений
}
Функция установки битов выглядит следующим образом:
private void saveBits(String value) {
byte[] digest = digest(value);
for (int i = 0; i + 3 < digest.length; i += 4) {
bitSet.set(bitsToInt(digest, i));
}
}
Функция проверки принадлежности значения множеству:
private boolean checkBits(String value) {
byte[] digest = digest(value);
for (int i = 0; i + 3 < digest.length; i += 4) {
if(!bitSet.get(bitsToInt(digest, i))) {
return false;
}
}
return true;
}
Пример применения фильтра Блума на 20 миллионах ИИН https://github.com/Nyquest/bloom_filter
Проверять фильтр Блума будем следующим образом:
В примере две реализации: собственная MyBloomFilter и GoogleBloomFilter(BloomFilter из guava).
При создании GoogleBloomFilter указывается вероятность ошибок, которая подтверждается эмпирическим путем.
Но так как наш фильтр модифицирован дважды, эмпирические данные показывают ошибки в 0.18% от всех тестовых запросов:
Теперь рассчитаем ожидаемую вероятность ошибок по формуле:
Функция расчета вероятности ошибок на Java
/**
* Расчет вероятности ошибок
* @param totalCount общее кол-во элементов
* @param bitArraySize размер битового массив
* @param hashFunctionCount кол-во хэш-функций
* @return вероятность ошибок
*/
public static double errorProbability(long totalCount, long bitArraySize, long hashFunctionCount) {
return Math.pow(1 - Math.pow(Math.E,
-(double)hashFunctionCount * totalCount / bitArraySize), hashFunctionCount);
}
}
Подставим наши значения и получим 0.0018, т.е. теоретические и практические результаты совпали.
System.out.println(errorProbability(20_000_000, 268_435_456, 12));
Получилась рабочая версия фильтра Блума, но с рядом недостатков.
Сравнивать будет с реализацией от Google, хотя почему-то в guava Bloom Filter еще в beta-тестировании.
Сравнение, конечно же, не в нашу пользу.
В реальных проектах лучше использовать готовые реализации Bloom Filter с потоковой безопасностью.
Основная цель своей реализации была лучше разобраться в этой интересной, вероятностной и магической структуре данных.
В реализации Google используется магия и трюки (см. фрагмент кода BloomFilterStrategies.java по добавлению элемента в множество):
@Override
public boolean put(
@ParametricNullness T object,
Funnel super T> funnel,
int numHashFunctions,
LockFreeBitArray bits) {
long bitSize = bits.bitSize();
long hash64 = Hashing.murmur3_128().hashObject(object, funnel).asLong();
int hash1 = (int) hash64;
int hash2 = (int) (hash64 >>> 32);
boolean bitsChanged = false;
for (int i = 1; i <= numHashFunctions; i++) {
int combinedHash = hash1 + (i * hash2);
// Flip all the bits if it's negative (guaranteed positive number)
if (combinedHash < 0) {
combinedHash = ~combinedHash;
}
bitsChanged |= bits.set(combinedHash % bitSize);
}
return bitsChanged;
}
Здесь один хэш получается из предыдущего. Если в вычислениях случается переполнение, в результате которого образумется отрицательное число, то происходит инверсия бит, чтобы получилось положительное число за счет смены бита, отвечающего за знак в том числе. Чтобы не выходить за пределы битового массива делается закольцовывание с помощью взятия остатка от деления на размер массива.
В следующем блоге смотрите эффективность фильтра Блума с точки зрения расхода памяти: Эффективность фильтра Блума в памяти
Функция htons преобразует узловой порядок расположения байтов в сетевой порядок расположения байтов. В short помещаются все возможные порты от 0 до 65535 (фактически от -32768 до 32767, т.к. в Java знаковый short):
static short htons(short val) {
return (short) ((val & 0xff) << 8
| (val & 0xff00) >>> 8); // >>> - беззнаковый сдвиг
}
Функция htonl() преобразует узловой порядок расположения байтов в сетевой порядок расположения байтов. В int помещаются все возможные адреса IPv4 от 0.0.0.0 до 255.255.255.255 (фактически от -2 147 483 648 до 2 147 483 647, т.к. в Java знаковый int):
static int htonl(int val) {
return (val & 0xff) << 24
| (val & 0xff00) << 8
| (val & 0xff0000) >> 8
| (val & 0xff000000) >>> 24; // >>> - беззнаковый сдвиг
}
public static int[] getSuffixArray(String s) {
System.out.println(s);
int N = s.length();
int steps = Integer.bitCount(Integer.highestOneBit(N) - 1);
int rank[][] = new int[steps + 1][N];
for (int i = 0; i < N; i++) {
rank[0][i] = s.charAt(i) - 'a';
}
Tuple tuples[] = new Tuple[N];
for (int step = 1, cnt = 1; step <= steps; step++, cnt <<= 1) {
for (int i = 0; i < N; i++) {
Tuple tuple = new Tuple();
tuple.firstHalf = rank[step - 1][i];
tuple.secondHalf = i + cnt < N ? rank[step - 1][i + cnt] : -1;
tuple.originalIndex = i;
tuples[i] = tuple;
}
Arrays.sort(tuples);
rank[step][tuples[0].originalIndex] = 0;
for (int i = 1, currRank = 0; i < N; i++) {
if(!tuples[i - 1].firstHalf.equals(tuples[i].firstHalf)
|| tuples[i - 1].secondHalf.equals(tuples[i].secondHalf)) {
++currRank;
}
rank[step][tuples[i].originalIndex] = currRank;
}
}
int suffixArray[] = new int[N];
for (int i = 0; i < N; i++) {
suffixArray[i] = tuples[i].originalIndex;
}
return suffixArray;
}
static class Tuple implements Comparable {
Integer originalIndex; // хранит оригинальный индекс суффикса
Integer firstHalf; // хранит ранг первой половины суффикса
Integer secondHalf; // хранит ранг второй половины суффикса
@Override
public int compareTo(Tuple tuple) {
return this.firstHalf.equals(tuple.firstHalf)
? this.secondHalf.compareTo(tuple.secondHalf)
: this.firstHalf.compareTo(tuple.firstHalf);
}
@Override
public String toString() {
return "Tuple{" +
"originalIndex=" + originalIndex +
", firstHalf=" + firstHalf +
", secondHalf=" + secondHalf +
'}';
}
}
String val = "55";
String collect = Arrays.stream(
"55,1024,55,15,8,0,55".split(","))
.filter(v -> !v.equals(val))
.collect(Collectors.joining(","));
System.out.println(collect);//1024,15,8,0
Реализация 2-го варианта (Java):
int del = 55;
String result = "55,1024,55,15,8,0,55"
.replaceAll("," + del + ",", ",")
.replaceAll(",?" + del + ",?", "");
System.out.println(result);//1024,15,8,0
-----BEGIN CERTIFICATE----- MIIEdjCCA16gAwIBAgIUGyZeQnd4LMFso5FQwrzjHmrNWVswDQYJKoZIhvcNAQEF utCwINC/0L7QtNC70LjQvdC90L7RgdGC0Lgg0YHQtdGA0LLQtdGA0LAwMgYDVR0f KqOTBEhH50jwo6WaQIUrD54ElD5gVO3VIT+eAMZm0HzXF+NKpkNaiR1b -----END CERTIFICATE-----Далее необходимо выполнить следующую команду:
sudo keytool -import -alias yourname -file test.crt
-keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit
-alias - произвольное уникальное название
-file - путь к файлу
-keystore - путь к хранилищу сертификатов Java
$JAVA_HOME - путь к домашней директории Java, которую вы используете для запуска вашего приложения
-storepass - пароль к хранилищу, по умолчанию changeit
private static String getFileContent(String filePath) throws IOException {
ApplicationContext appContext =
new ClassPathXmlApplicationContext(new String[] {});
Resource resource = appContext.getResource(filePath);
StringBuilder sb = new StringBuilder();
BufferedReader br = null;
try{
br = new BufferedReader(
new InputStreamReader(resource.getInputStream(), "UTF-8"));
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
}finally {
if(br != null) try {
br.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return sb.toString();
}
Пример вызова функции:
try {
String text = getFileContent("files/cities.json");
} catch (IOException e) {
e.printStackTrace();
}
Я использовал чтение файла для временной заглушки, но можно использовать и для других целей.
p.s. Обратите внимание, что в функции указана кодировка UTF-8. Можно её не указывать, тогда возьмется кодировка системы. Кодировку можно указать и при запуске приложения:
java -Dfile.encoding=UTF8Совет: лучше в функции явно прописать UTF-8, если, конечно, она вам нужна.
long start = System.currentTimeMillis();
//тут замеряемый код
System.out.println(System.currentTimeMillis() - start);
Но такой подход быстро надоедает. Я выделил замер времени в отдельный класс TimeMeter, теперь можно удобно останавливать несколько раз таймер и измерять время, если нужно измерить промежуточные значения. Можно также использовать несколько экземпляров таймера.
Реализация:
public class TimeMeter {
private long start;
private long stop;
public TimeMeter(){
start = now();
}
public void stop(){
stop = now();
}
@Override
public String toString() {
return getDuration() + " msec";
}
public long getDuration(){
return (stop == 0 ? now() : stop) - start;
}
private long now(){
return System.currentTimeMillis();
}
}
Самый простой вариант использования:
TimeMeter timeMeter = new TimeMeter();
//тут замеряемый код
System.out.println(timeMeter);
Таймер с несколькими остановками и замерами времени:
TimeMeter timeMeter = new TimeMeter();
//тут замеряемый код 1
timeMeter.stop();
System.out.println(timeMeter);
//тут замеряемый код 2
timeMeter.stop();
System.out.println(timeMeter);
Глобальный и внутренние таймеры:
TimeMeter totalTimeMeter = new TimeMeter();
for (int i = 0; i < 100; i++) {
TimeMeter timeMeter = new TimeMeter();
//тут замеряемый код
System.out.println("time " + i + ": " + timeMeter);
}
System.out.println("total time: " + totalTimeMeter);
p.s. Можете придумать и сделать свою версию, здесь простор для творчества.
public static String pickPhrase(int count, String word0,String word1,String word2) {
int rem = count % 100;
if(rem < 11 || rem > 14){
rem = count % 10;
if(rem == 1) return word1;
if(rem >= 2 && rem <= 4) return word2;
} return word0;
}
Функция pickPhrase принимает количество (count) и три вида окончаний (word0, word1, word2).
Мысленно можно выделить следующие группы:
0 карточек, 5 карточек, 6 карточек и т.д.
1 карточка, 21 карточка, 31 карточка и т.д.
2 карточки, 3 карточки, 4 карточки и т.д.
Для удобства я взял окончания для 0, 1, 2, т.к. у них троих различные окончания.
Вам только остается подобрать окончания для 0, 1, 2, а функция по переданную количеству подберет требуемое окончание.
word0 - соответствует окончанию для 0-го количества;
word1 - соответствует окончанию с количеством в 1 штуку;
word2 - соответствует окончанию с количеством в 2 штуки.
Например:
Пусть дано кол-во карточек от 0 до 100 включительно и нужно к слову "карточка" подобрать окончание в соответствии с количеством.
Это делается так:
for (int i = 0; i <= 100; i++) {
System.out.println(i + " карточ" + pickPhrase(i, "ек", "ка", "ки"));
}
Мы выделили неизменяемую часть слова "карточ" и далее нашей функцией подобрали окончание.
0 карточек 1 карточка 2 карточки 3 карточки 4 карточки 5 карточек 6 карточек 7 карточек 8 карточек 9 карточек 10 карточек 11 карточек 12 карточек ... 96 карточек 97 карточек 98 карточек 99 карточек 100 карточекЧтобы не выделить неизменяемую часть слова и не допустить ошибку при этом, можно вместо окончаний использовать сами слова. Например:
for (int i = 0; i <= 100; i++) {
System.out.println(i + " " + pickPhrase(i, "штук", "штука", "штуки"));
}
0 штук
1 штука
2 штуки
3 штуки
4 штуки
5 штук
6 штук
7 штук
8 штук
9 штук
10 штук
11 штук
12 штук
13 штук
...
90 штук
91 штука
92 штуки
93 штуки
94 штуки
95 штук
96 штук
97 штук
98 штук
99 штук
100 штук